[Security Solution][Endpoint] Remove redux from endpoint list page etc. (#161253)

## Summary

Removes the following from `management` redux store
- `endpointDetails` and child keys (`hostInfo`, `hostInfoError` and
`isHostInfoLoading`)
- `policyResponse`, `policyResponseLoading` and `policyResponseError`
- `hostStatus`
- rearranged/refactored a huge chunk of endpoint list component to make
it more manageable for later when we remove endpoint list data out of
redux
- endpoint list nav link component:
0e281f1191
- back to policy link component:
9e3c771eb7
- extracted table columns out of the component:
3728ca604e
  - rearranged variables: 09bb01a1d2
- transform callout component: 808fa81373

### Checklist
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
This commit is contained in:
Ashokaditya 2023-07-10 17:08:59 +02:00 committed by GitHub
parent bf148fb35f
commit ddd58d9bfd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 892 additions and 1214 deletions

View file

@ -8,7 +8,6 @@
import type { Action } from 'redux';
import type { DataViewBase } from '@kbn/es-query';
import type {
HostInfo,
GetHostPolicyResponse,
HostIsolationRequestBody,
ISOLATION_ACTIONS,
@ -28,15 +27,6 @@ export interface ServerFailedToReturnEndpointList {
payload: ServerApiError;
}
export interface ServerReturnedEndpointDetails {
type: 'serverReturnedEndpointDetails';
payload: HostInfo;
}
export interface ServerFailedToReturnEndpointDetails {
type: 'serverFailedToReturnEndpointDetails';
payload: ServerApiError;
}
export interface ServerReturnedEndpointPolicyResponse {
type: 'serverReturnedEndpointPolicyResponse';
payload: GetHostPolicyResponse;
@ -150,13 +140,6 @@ export type EndpointPendingActionsStateChanged = Action<'endpointPendingActionsS
payload: EndpointState['endpointPendingActions'];
};
export interface EndpointDetailsLoad {
type: 'endpointDetailsLoad';
payload: {
endpointId: string;
};
}
export type LoadMetadataTransformStats = Action<'loadMetadataTransformStats'>;
export type MetadataTransformStatsChanged = Action<'metadataTransformStatsChanged'> & {
@ -166,11 +149,6 @@ export type MetadataTransformStatsChanged = Action<'metadataTransformStatsChange
export type EndpointAction =
| ServerReturnedEndpointList
| ServerFailedToReturnEndpointList
| ServerReturnedEndpointDetails
| ServerFailedToReturnEndpointDetails
| EndpointDetailsLoad
| ServerReturnedEndpointPolicyResponse
| ServerFailedToReturnEndpointPolicyResponse
| ServerReturnedPoliciesForOnboarding
| ServerFailedToReturnPoliciesForOnboarding
| UserSelectedEndpointPolicy

View file

@ -18,14 +18,6 @@ export const initialEndpointPageState = (): Immutable<EndpointState> => {
total: 0,
loading: false,
error: undefined,
endpointDetails: {
hostInfo: undefined,
hostInfoError: undefined,
isHostInfoLoading: false,
},
policyResponse: undefined,
policyResponseLoading: false,
policyResponseError: undefined,
location: undefined,
policyItems: [],
selectedPolicyId: undefined,
@ -42,8 +34,6 @@ export const initialEndpointPageState = (): Immutable<EndpointState> => {
agentsWithEndpointsTotalError: undefined,
endpointsTotal: 0,
endpointsTotalError: undefined,
policyVersionInfo: undefined,
hostStatus: undefined,
isolationRequestState: createUninitialisedResourceState(),
endpointPendingActions: createLoadedResourceState(new Map()),
metadataTransformStats: createUninitialisedResourceState(),

View file

@ -43,14 +43,6 @@ describe('EndpointList store concerns', () => {
total: 0,
loading: false,
error: undefined,
endpointDetails: {
hostInfo: undefined,
hostInfoError: undefined,
isHostInfoLoading: false,
},
policyResponse: undefined,
policyResponseLoading: false,
policyResponseError: undefined,
location: undefined,
policyItems: [],
selectedPolicyId: undefined,
@ -70,7 +62,6 @@ describe('EndpointList store concerns', () => {
agentsWithEndpointsTotalError: undefined,
endpointsTotalError: undefined,
queryStrategyVersion: undefined,
policyVersionInfo: undefined,
isolationRequestState: {
type: 'UninitialisedResourceState',
},

View file

@ -17,8 +17,8 @@ import { depsStartMock } from '../../../../common/mock/endpoint';
import type { MiddlewareActionSpyHelper } from '../../../../common/store/test_utils';
import { createSpyMiddleware } from '../../../../common/store/test_utils';
import type {
Immutable,
HostIsolationResponse,
Immutable,
ISOLATION_ACTIONS,
MetadataListResponse,
} from '../../../../../common/endpoint/types';
@ -28,8 +28,7 @@ import { listData } from './selectors';
import type { EndpointState, TransformStats } from '../types';
import { endpointListReducer } from './reducer';
import { endpointMiddlewareFactory } from './middleware';
import { getEndpointListPath, getEndpointDetailsPath } from '../../../common/routing';
import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables';
import { getEndpointListPath } from '../../../common/routing';
import type { FailedResourceState, LoadedResourceState } from '../../../state';
import {
isFailedResourceState,
@ -43,10 +42,7 @@ import {
hostIsolationResponseMock,
} from '../../../../common/lib/endpoint_isolation/mocks';
import { endpointPageHttpMock, failedTransformStateMock } from '../mocks';
import {
HOST_METADATA_GET_ROUTE,
HOST_METADATA_LIST_ROUTE,
} from '../../../../../common/endpoint/constants';
import { HOST_METADATA_LIST_ROUTE } from '../../../../../common/endpoint/constants';
jest.mock('../../../services/policies/ingest', () => ({
sendGetAgentConfigList: () => Promise.resolve({ items: [] }),
@ -343,66 +339,4 @@ describe('endpoint list middleware', () => {
expect(failedAction.error).toBe(apiError);
});
});
describe('loads selected endpoint details', () => {
beforeEach(() => {
endpointPageHttpMock(fakeHttpServices);
});
const endpointList = getEndpointListApiResponse();
const agentId = endpointList.data[0].metadata.agent.id;
const search = getEndpointDetailsPath({
name: 'endpointDetails',
selected_endpoint: agentId,
});
const dispatchUserChangedUrl = () => {
dispatchUserChangedUrlToEndpointList({ search: `?${search.split('?').pop()}` });
};
it('triggers the endpoint details related actions when the url is changed', async () => {
dispatchUserChangedUrl();
// Note: these are left intentionally in sequence
// to test specific race conditions that currently exist in the middleware
await waitForAction('serverCancelledPolicyItemsLoading');
// loads the endpoints list
await waitForAction('serverReturnedEndpointList');
// loads the specific endpoint details
await waitForAction('serverReturnedEndpointDetails');
// loads the specific endpoint pending actions
await waitForAction('endpointPendingActionsStateChanged');
expect(fakeHttpServices.get).toHaveBeenCalledWith(
resolvePathVariables(HOST_METADATA_GET_ROUTE, { id: agentId }),
{ version: '2023-10-31' }
);
});
it('handles the endpointDetailsLoad action', async () => {
const endpointId = agentId;
dispatch({
type: 'endpointDetailsLoad',
payload: {
endpointId,
},
});
// note: this action does not load the endpoints list
// loads the specific endpoint details
await waitForAction('serverReturnedEndpointDetails');
await waitForAction('serverReturnedEndpointNonExistingPolicies');
// loads the specific endpoint pending actions
await waitForAction('endpointPendingActionsStateChanged');
expect(fakeHttpServices.get).toHaveBeenCalledWith(
resolvePathVariables(HOST_METADATA_GET_ROUTE, { id: endpointId }),
{ version: '2023-10-31' }
);
});
});
});

View file

@ -15,17 +15,13 @@ import type {
IndexFieldsStrategyResponse,
} from '@kbn/timelines-plugin/common';
import {
BASE_POLICY_RESPONSE_ROUTE,
ENDPOINT_FIELDS_SEARCH_STRATEGY,
HOST_METADATA_GET_ROUTE,
HOST_METADATA_LIST_ROUTE,
METADATA_TRANSFORMS_STATUS_ROUTE,
METADATA_UNITED_INDEX,
metadataCurrentIndexPattern,
} from '../../../../../common/endpoint/constants';
import type {
GetHostPolicyResponse,
HostInfo,
HostIsolationRequestBody,
HostResultList,
Immutable,
@ -37,7 +33,6 @@ import { isolateHost, unIsolateHost } from '../../../../common/lib/endpoint_isol
import { fetchPendingActionsByAgentId } from '../../../../common/lib/endpoint_pending_actions';
import type { ImmutableMiddlewareAPI, ImmutableMiddlewareFactory } from '../../../../common/store';
import type { AppAction } from '../../../../common/store/actions';
import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables';
import { sendGetEndpointSpecificPackagePolicies } from '../../../services/policies/policies';
import {
asStaleResourceState,
@ -61,12 +56,10 @@ import type { EndpointPackageInfoStateChanged } from './action';
import {
endpointPackageInfo,
endpointPackageVersion,
fullDetailsHostInfo,
getCurrentIsolationRequestState,
getIsEndpointPackageInfoUninitialized,
getIsIsolationRequestPending,
getMetadataTransformStats,
hasSelectedEndpoint,
isMetadataTransformStatsLoading,
isOnEndpointPage,
listData,
@ -126,20 +119,9 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory<EndpointState
// Endpoint list
if (
(action.type === 'userChangedUrl' || action.type === 'appRequestedEndpointList') &&
isOnEndpointPage(getState()) &&
!hasSelectedEndpoint(getState())
isOnEndpointPage(getState())
) {
await endpointDetailsListMiddleware({ coreStart, store, fetchIndexPatterns });
}
// Endpoint Details
if (action.type === 'userChangedUrl' && hasSelectedEndpoint(getState())) {
const { selected_endpoint: selectedEndpoint } = uiQueryParams(getState());
await endpointDetailsMiddleware({ store, coreStart, selectedEndpoint });
}
if (action.type === 'endpointDetailsLoad') {
await loadEndpointDetails({ store, coreStart, selectedEndpoint: action.payload.endpointId });
await endpointListMiddleware({ coreStart, store, fetchIndexPatterns });
}
// Isolate Host
@ -320,7 +302,7 @@ async function getEndpointPackageInfo(
}
/**
* retrieves the Endpoint pending actions for all of the existing endpoints being displayed on the list
* retrieves the Endpoint pending actions for all the existing endpoints being displayed on the list
* or the details tab.
*
* @param store
@ -330,15 +312,9 @@ const loadEndpointsPendingActions = async ({
dispatch,
}: EndpointPageStore): Promise<void> => {
const state = getState();
const detailsEndpoint = fullDetailsHostInfo(state);
const listEndpoints = listData(state);
const agentsIds = new Set<string>();
// get all agent ids for the endpoints in the list
if (detailsEndpoint) {
agentsIds.add(detailsEndpoint.metadata.elastic.agent.id);
}
for (const endpointInfo of listEndpoints) {
agentsIds.add(endpointInfo.metadata.elastic.agent.id);
}
@ -366,7 +342,7 @@ const loadEndpointsPendingActions = async ({
}
};
async function endpointDetailsListMiddleware({
async function endpointListMiddleware({
store,
coreStart,
fetchIndexPatterns,
@ -407,7 +383,7 @@ async function endpointDetailsListMiddleware({
});
}
// get index pattern and fields for search bar
// get an index pattern and fields for search bar
if (patterns(getState()).length === 0) {
try {
const indexPatterns = await fetchIndexPatterns(getState());
@ -445,7 +421,7 @@ async function endpointDetailsListMiddleware({
const policyDataResponse: GetPolicyListResponse =
await sendGetEndpointSpecificPackagePolicies(http, {
query: {
perPage: 50, // Since this is an oboarding flow, we'll cap at 50 policies.
perPage: 50, // Since this is an onboarding flow, we'll cap at 50 policies.
page: 1,
},
});
@ -474,130 +450,6 @@ async function endpointDetailsListMiddleware({
}
}
async function loadEndpointDetails({
selectedEndpoint,
store,
coreStart,
}: {
store: ImmutableMiddlewareAPI<EndpointState, AppAction>;
coreStart: CoreStart;
selectedEndpoint: string;
}) {
const { getState, dispatch } = store;
// call the endpoint details api
try {
const response = await coreStart.http.get<HostInfo>(
resolvePathVariables(HOST_METADATA_GET_ROUTE, { id: selectedEndpoint as string }),
{ version: '2023-10-31' }
);
dispatch({
type: 'serverReturnedEndpointDetails',
payload: response,
});
try {
const ingestPolicies = await getAgentAndPoliciesForEndpointsList(
coreStart.http,
[response],
nonExistingPolicies(getState())
);
if (ingestPolicies !== undefined) {
dispatch({
type: 'serverReturnedEndpointNonExistingPolicies',
payload: ingestPolicies.packagePolicy,
});
}
if (ingestPolicies?.agentPolicy !== undefined) {
dispatch({
type: 'serverReturnedEndpointAgentPolicies',
payload: ingestPolicies.agentPolicy,
});
}
} catch (error) {
// TODO should handle the error instead of logging it to the browser
// Also this is an anti-pattern we shouldn't use
// Ignore Errors, since this should not hinder the user's ability to use the UI
logError(error);
}
} catch (error) {
dispatch({
type: 'serverFailedToReturnEndpointDetails',
payload: error,
});
}
loadEndpointsPendingActions(store);
// call the policy response api
try {
const policyResponse = await coreStart.http.get<GetHostPolicyResponse>(
BASE_POLICY_RESPONSE_ROUTE,
{
version: '2023-10-31',
query: { agentId: selectedEndpoint },
}
);
dispatch({
type: 'serverReturnedEndpointPolicyResponse',
payload: policyResponse,
});
} catch (error) {
dispatch({
type: 'serverFailedToReturnEndpointPolicyResponse',
payload: error,
});
}
}
async function endpointDetailsMiddleware({
coreStart,
selectedEndpoint,
store,
}: {
coreStart: CoreStart;
selectedEndpoint?: string;
store: ImmutableMiddlewareAPI<EndpointState, AppAction>;
}) {
const { getState, dispatch } = store;
dispatch({
type: 'serverCancelledPolicyItemsLoading',
});
// If user navigated directly to a endpoint details page, load the endpoint list
if (listData(getState()).length === 0) {
const { page_index: pageIndex, page_size: pageSize } = uiQueryParams(getState());
try {
const response = await coreStart.http.get<MetadataListResponse>(HOST_METADATA_LIST_ROUTE, {
version: '2023-10-31',
query: {
page: pageIndex,
pageSize,
},
});
dispatch({
type: 'serverReturnedEndpointList',
payload: response,
});
dispatchIngestPolicies({ http: coreStart.http, hosts: response.data, store });
} catch (error) {
dispatch({
type: 'serverFailedToReturnEndpointList',
payload: error,
});
}
} else {
dispatch({
type: 'serverCancelledEndpointListLoading',
});
}
if (typeof selectedEndpoint === 'undefined') {
return;
}
await loadEndpointDetails({ store, coreStart, selectedEndpoint });
}
export async function handleLoadMetadataTransformStats(http: HttpStart, store: EndpointPageStore) {
const { getState, dispatch } = store;

View file

@ -12,7 +12,6 @@ import type {
} from './action';
import {
getCurrentIsolationRequestState,
getIsOnEndpointDetailsActivityLog,
hasSelectedEndpoint,
isOnEndpointPage,
uiQueryParams,
@ -108,27 +107,6 @@ export const endpointListReducer: StateReducer = (state = initialEndpointPageSta
...state,
patternsError: action.payload,
};
} else if (action.type === 'serverReturnedEndpointDetails') {
return {
...state,
endpointDetails: {
...state.endpointDetails,
hostInfo: action.payload,
hostInfoError: undefined,
isHostInfoLoading: false,
},
policyVersionInfo: action.payload.policy_info,
hostStatus: action.payload.host_status,
};
} else if (action.type === 'serverFailedToReturnEndpointDetails') {
return {
...state,
endpointDetails: {
...state.endpointDetails,
hostInfoError: action.payload,
isHostInfoLoading: false,
},
};
} else if (action.type === 'endpointPendingActionsStateChanged') {
return handleEndpointPendingActionsStateChanged(state, action);
} else if (action.type === 'serverReturnedPoliciesForOnboarding') {
@ -143,24 +121,10 @@ export const endpointListReducer: StateReducer = (state = initialEndpointPageSta
error: action.payload,
policyItemsLoading: false,
};
} else if (action.type === 'serverReturnedEndpointPolicyResponse') {
return {
...state,
policyResponse: action.payload.policy_response,
policyResponseLoading: false,
policyResponseError: undefined,
};
} else if (action.type === 'serverFailedToReturnEndpointPolicyResponse') {
return {
...state,
policyResponseError: action.payload,
policyResponseLoading: false,
};
} else if (action.type === 'userSelectedEndpointPolicy') {
return {
...state,
selectedPolicyId: action.payload.selectedPolicyId,
policyResponseLoading: false,
};
} else if (action.type === 'serverCancelledEndpointListLoading') {
return {
@ -218,25 +182,10 @@ export const endpointListReducer: StateReducer = (state = initialEndpointPageSta
const wasPreviouslyOnListPage = isOnEndpointPage(state) && !hasSelectedEndpoint(state);
const isCurrentlyOnDetailsPage = isOnEndpointPage(newState) && hasSelectedEndpoint(newState);
const wasPreviouslyOnDetailsPage = isOnEndpointPage(state) && hasSelectedEndpoint(state);
const wasPreviouslyOnActivityLogPage =
isOnEndpointPage(state) &&
hasSelectedEndpoint(state) &&
getIsOnEndpointDetailsActivityLog(state);
const isCurrentlyOnActivityLogPage =
isOnEndpointPage(newState) &&
hasSelectedEndpoint(newState) &&
getIsOnEndpointDetailsActivityLog(newState);
const isNotLoadingDetails =
isCurrentlyOnActivityLogPage ||
(wasPreviouslyOnActivityLogPage &&
uiQueryParams(state).selected_endpoint === uiQueryParams(newState).selected_endpoint);
const stateUpdates: Partial<EndpointState> = {
location: action.payload,
error: undefined,
policyResponseError: undefined,
};
// Reset `isolationRequestState` if needed
@ -253,10 +202,6 @@ export const endpointListReducer: StateReducer = (state = initialEndpointPageSta
return {
...state,
...stateUpdates,
endpointDetails: {
...state.endpointDetails,
hostInfoError: undefined,
},
loading: true,
policyItemsLoading: true,
};
@ -267,26 +212,15 @@ export const endpointListReducer: StateReducer = (state = initialEndpointPageSta
return {
...state,
...stateUpdates,
endpointDetails: {
...state.endpointDetails,
hostInfoError: undefined,
isHostInfoLoading: !isNotLoadingDetails,
},
detailsLoading: true,
policyResponseLoading: true,
};
} else {
// if the previous page was not endpoint list or endpoint details, load both list and details
return {
...state,
...stateUpdates,
endpointDetails: {
...state.endpointDetails,
hostInfoError: undefined,
isHostInfoLoading: true,
},
loading: true,
policyResponseLoading: true,
policyItemsLoading: true,
};
}
@ -295,10 +229,6 @@ export const endpointListReducer: StateReducer = (state = initialEndpointPageSta
return {
...state,
...stateUpdates,
endpointDetails: {
...state.endpointDetails,
hostInfoError: undefined,
},
endpointsExist: true,
};
} else if (action.type === 'metadataTransformStatsChanged') {

View file

@ -12,7 +12,6 @@ import { matchPath } from 'react-router-dom';
import { decode } from '@kbn/rison';
import type { Query } from '@kbn/es-query';
import type { EndpointPendingActions, Immutable } from '../../../../../common/endpoint/types';
import { HostStatus } from '../../../../../common/endpoint/types';
import type { EndpointIndexUIQueryParams, EndpointState } from '../types';
import { extractListPaginationParams } from '../../../common/routing';
import {
@ -28,7 +27,6 @@ import {
} from '../../../state';
import type { ServerApiError } from '../../../../common/types';
import { isEndpointHostIsolated } from '../../../../common/utils/validators';
import { EndpointDetailsTabsTypes } from '../view/details/components/endpoint_details_tabs';
export const listData = (state: Immutable<EndpointState>) => state.hosts;
@ -43,18 +41,6 @@ export const listLoading = (state: Immutable<EndpointState>): boolean => state.l
export const listError = (state: Immutable<EndpointState>) => state.error;
export const fullDetailsHostInfo = (
state: Immutable<EndpointState>
): EndpointState['endpointDetails']['hostInfo'] => state.endpointDetails.hostInfo;
export const isHostInfoLoading = (
state: Immutable<EndpointState>
): EndpointState['endpointDetails']['isHostInfoLoading'] => state.endpointDetails.isHostInfoLoading;
export const hostInfoError = (
state: Immutable<EndpointState>
): EndpointState['endpointDetails']['hostInfoError'] => state.endpointDetails.hostInfoError;
export const policyItems = (state: Immutable<EndpointState>) => state.policyItems;
export const policyItemsLoading = (state: Immutable<EndpointState>) => state.policyItemsLoading;
@ -68,14 +54,12 @@ export const isAutoRefreshEnabled = (state: Immutable<EndpointState>) => state.i
export const autoRefreshInterval = (state: Immutable<EndpointState>) => state.autoRefreshInterval;
export const policyVersionInfo = (state: Immutable<EndpointState>) => state.policyVersionInfo;
export const endpointPackageVersion = createSelector(endpointPackageInfo, (info) =>
isLoadedResourceState(info) ? info.data.version : undefined
);
/**
* Returns the index patterns for the SearchBar to use for autosuggest
* Returns the index patterns for the SearchBar to use for auto-suggest
*/
export const patterns = (state: Immutable<EndpointState>) => state.patterns;
@ -160,26 +144,6 @@ export const showView: (state: EndpointState) => EndpointIndexUIQueryParams['sho
return searchParams.show ?? 'details';
});
/**
* Returns the Host Status which is connected the fleet agent
*/
export const hostStatusInfo: (state: Immutable<EndpointState>) => HostStatus = createSelector(
(state: Immutable<EndpointState>) => state.hostStatus,
(hostStatus) => {
return hostStatus ? hostStatus : HostStatus.UNHEALTHY;
}
);
/**
* Returns the Policy Response overall status
*/
export const policyResponseStatus: (state: Immutable<EndpointState>) => string = createSelector(
(state: Immutable<EndpointState>) => state.policyResponse,
(policyResponse) => {
return (policyResponse && policyResponse?.Endpoint?.policy?.applied?.status) || '';
}
);
/**
* returns the list of known non-existing polices that may have been in the Endpoint API response.
* @param state
@ -255,10 +219,6 @@ export const getIsOnEndpointDetailsActivityLog: (state: Immutable<EndpointState>
return searchParams.show === EndpointDetailsTabsTypes.activityLog;
});
export const getIsEndpointHostIsolated = createSelector(fullDetailsHostInfo, (details) => {
return (details && isEndpointHostIsolated(details.metadata)) || false;
});
export const getEndpointPendingActionsState = (
state: Immutable<EndpointState>
): Immutable<EndpointState['endpointPendingActions']> => {

View file

@ -11,8 +11,6 @@ import type {
AppLocation,
EndpointPendingActions,
HostInfo,
HostPolicyResponse,
HostStatus,
Immutable,
PolicyData,
ResponseActionApiResponse,
@ -34,19 +32,6 @@ export interface EndpointState {
loading: boolean;
/** api error from retrieving host list */
error?: ServerApiError;
endpointDetails: {
// Adding `hostInfo` to store full API response in order to support the
// refactoring effort with AgentStatus component
hostInfo?: HostInfo;
hostInfoError?: ServerApiError;
isHostInfoLoading: boolean;
};
/** Holds the Policy Response for the Host currently being displayed in the details */
policyResponse?: HostPolicyResponse;
/** policyResponse is being retrieved */
policyResponseLoading: boolean;
/** api error from retrieving the policy response */
policyResponseError?: ServerApiError;
/** current location info */
location?: Immutable<AppLocation>;
/** policies */
@ -57,7 +42,7 @@ export interface EndpointState {
selectedPolicyId?: string;
/** Endpoint package info */
endpointPackageInfo: AsyncResourceState<GetInfoResponse['item']>;
/** Tracks the list of policies IDs used in Host metadata that may no longer exist */
/** Tracks the list of policy IDs used in Host metadata that may no longer exist */
nonExistingPolicies: PolicyIds['packagePolicy'];
/** List of Package Policy Ids mapped to an associated Fleet Parent Agent Policy Id*/
agentPolicies: PolicyIds['agentPolicy'];
@ -67,7 +52,7 @@ export interface EndpointState {
patterns: DataViewBase[];
/** api error from retrieving index patters for query bar */
patternsError?: ServerApiError;
/** Is auto-refresh enabled */
/** Is auto-refresh enabled? */
isAutoRefreshEnabled: boolean;
/** The current auto refresh interval for data in ms */
autoRefreshInterval: number;
@ -79,10 +64,6 @@ export interface EndpointState {
endpointsTotal: number;
/** api error for total, actual Endpoints */
endpointsTotalError?: ServerApiError;
/** The policy IDs and revision number of the corresponding agent, and endpoint. May be more recent than what's running */
policyVersionInfo?: HostInfo['policy_info'];
/** The status of the host, which is mapped to the Elastic Agent status in Fleet */
hostStatus?: HostStatus;
/** Host isolation request state for a single endpoint */
isolationRequestState: AsyncResourceState<ResponseActionApiResponse>;
/**

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { useAppUrl } from '../../../../../common/lib/kibana';
import type { BackToExternalAppButtonProps } from '../../../../components/back_to_external_app_button/back_to_external_app_button';
import { BackToExternalAppButton } from '../../../../components/back_to_external_app_button/back_to_external_app_button';
import { getPoliciesPath } from '../../../../common/routing';
import { APP_UI_ID } from '../../../../../../common';
import type { PolicyDetailsRouteState } from '../../../../../../common/endpoint/types';
export const BackToPolicyListButton = memo<{ backLink?: PolicyDetailsRouteState['backLink'] }>(
({ backLink }) => {
const { getAppUrl } = useAppUrl();
const backLinkOptions = useMemo<BackToExternalAppButtonProps>(() => {
if (backLink) {
const { navigateTo, label, href } = backLink;
return {
onBackButtonNavigateTo: navigateTo,
backButtonLabel: label,
backButtonUrl: href,
};
}
// the default back button is to the policy list
const policyListPath = getPoliciesPath();
return {
backButtonLabel: i18n.translate('xpack.securitySolution.endpoint.list.backToPolicyButton', {
defaultMessage: 'Back to policy list',
}),
backButtonUrl: getAppUrl({ path: policyListPath }),
onBackButtonNavigateTo: [
APP_UI_ID,
{
path: policyListPath,
},
],
};
}, [getAppUrl, backLink]);
if (!backLink) {
return null;
}
return <BackToExternalAppButton {...backLinkOptions} data-test-subj="endpointListBackLink" />;
}
);
BackToPolicyListButton.displayName = 'BackToPolicyListButton';

View file

@ -0,0 +1,32 @@
/*
* 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 { EuiLink } from '@elastic/eui';
import { useNavigateByRouterEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_by_router_event_handler';
export const EndpointListNavLink = memo<{
name: string;
href: string;
route: string;
dataTestSubj: string;
}>(({ name, href, route, dataTestSubj }) => {
const clickHandler = useNavigateByRouterEventHandler(route);
return (
// eslint-disable-next-line @elastic/eui/href-or-on-click
<EuiLink
data-test-subj={dataTestSubj}
className="eui-displayInline eui-textTruncate"
href={href}
onClick={clickHandler}
>
{name}
</EuiLink>
);
});
EndpointListNavLink.displayName = 'EndpointListNavLink';

View file

@ -0,0 +1,140 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiLink, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useDispatch } from 'react-redux';
import { useKibana } from '../../../../../common/lib/kibana';
import type { ImmutableArray } from '../../../../../../common/endpoint/types';
import type { TransformStats } from '../../types';
import { WARNING_TRANSFORM_STATES } from '../../../../../../common/constants';
import { metadataTransformPrefix } from '../../../../../../common/endpoint/constants';
import { LinkToApp } from '../../../../../common/components/endpoint/link_to_app';
import { CallOut } from '../../../../../common/components/callouts';
import type { EndpointAction } from '../../store/action';
const TRANSFORM_URL = '/data/transform';
interface TransformFailedCalloutProps {
hasNoPolicyData: boolean;
metadataTransformStats: ImmutableArray<TransformStats>;
}
export const TransformFailedCallout = memo<TransformFailedCalloutProps>(
({ hasNoPolicyData, metadataTransformStats }) => {
const [showTransformFailedCallout, setShowTransformFailedCallout] = useState(false);
const [shouldCheckTransforms, setShouldCheckTransforms] = useState(true);
const { services } = useKibana();
const dispatch = useDispatch<(a: EndpointAction) => void>();
useEffect(() => {
// if no endpoint policy, skip transform check
if (!shouldCheckTransforms || hasNoPolicyData) {
return;
}
dispatch({ type: 'loadMetadataTransformStats' });
setShouldCheckTransforms(false);
}, [dispatch, hasNoPolicyData, shouldCheckTransforms]);
useEffect(() => {
const hasFailure = metadataTransformStats.some((transform) =>
WARNING_TRANSFORM_STATES.has(transform?.state)
);
setShowTransformFailedCallout(hasFailure);
}, [metadataTransformStats]);
const closeTransformFailedCallout = useCallback(() => {
setShowTransformFailedCallout(false);
}, []);
const failingTransformIds: string = useMemo(
() =>
metadataTransformStats
.reduce<string[]>((acc, currentValue) => {
if (WARNING_TRANSFORM_STATES.has(currentValue.state)) {
acc.push(currentValue.id);
}
return acc;
}, [])
.join(', '),
[metadataTransformStats]
);
const calloutDescription = useMemo(
() => (
<>
<FormattedMessage
id="xpack.securitySolution.endpoint.list.transformFailed.message"
defaultMessage="A required transform, {transformId}, is currently failing. Most of the time this can be fixed by {transformsPage}. For additional help, please visit the {docsPage}"
values={{
transformId: failingTransformIds || metadataTransformPrefix,
transformsPage: (
<LinkToApp
data-test-subj="failed-transform-restart-link"
appId="management"
appPath={TRANSFORM_URL}
>
<FormattedMessage
id="xpack.securitySolution.endpoint.list.transformFailed.restartLink"
defaultMessage="restarting the transform"
/>
</LinkToApp>
),
docsPage: (
<EuiLink
data-test-subj="failed-transform-docs-link"
href={services.docLinks.links.endpoints.troubleshooting}
target="_blank"
>
<FormattedMessage
id="xpack.securitySolution.endpoint.list.transformFailed.docsLink"
defaultMessage="troubleshooting documentation"
/>
</EuiLink>
),
}}
/>
<EuiSpacer size="s" />
</>
),
[failingTransformIds, services.docLinks.links.endpoints.troubleshooting]
);
if (!showTransformFailedCallout) {
return <></>;
}
return (
<>
<CallOut
message={{
id: 'endpoints-list-transform-failed',
type: 'warning',
title: i18n.translate('xpack.securitySolution.endpoint.list.transformFailed.title', {
defaultMessage: 'Required transform failed',
}),
description: calloutDescription,
}}
dismissButtonText={i18n.translate(
'xpack.securitySolution.endpoint.list.transformFailed.dismiss',
{
defaultMessage: 'Dismiss',
}
)}
onDismiss={closeTransformFailedCallout}
showDismissButton={true}
/>
<EuiSpacer size="m" />
</>
);
}
);
TransformFailedCallout.displayName = 'TransformFailedCallout';

View file

@ -12,11 +12,12 @@ import { ActionsMenu } from './actions_menu';
import React from 'react';
import { act } from '@testing-library/react';
import { endpointPageHttpMock } from '../../../mocks';
import { fireEvent } from '@testing-library/dom';
import { licenseService } from '../../../../../../common/hooks/use_license';
import { useUserPrivileges } from '../../../../../../common/components/user_privileges';
import { initialUserPrivilegesState } from '../../../../../../common/components/user_privileges/user_privileges_context';
import { getUserPrivilegesMockDefaultValue } from '../../../../../../common/components/user_privileges/__mocks__';
import type { HostInfo } from '../../../../../../../common/endpoint/types';
import userEvent from '@testing-library/user-event';
jest.mock('../../../../../../common/lib/kibana/kibana_react', () => {
const originalModule = jest.requireActual('../../../../../../common/lib/kibana/kibana_react');
@ -39,12 +40,13 @@ jest.mock('../../../../../../common/components/user_privileges');
describe('When using the Endpoint Details Actions Menu', () => {
let render: () => Promise<ReturnType<AppContextTestRender['render']>>;
let coreStart: AppContextTestRender['coreStart'];
let waitForAction: AppContextTestRender['middlewareSpy']['waitForAction'];
let renderResult: ReturnType<AppContextTestRender['render']>;
let httpMocks: ReturnType<typeof endpointPageHttpMock>;
let middlewareSpy: AppContextTestRender['middlewareSpy'];
let endpointHost: HostInfo;
const setEndpointMetadataResponse = (isolation: boolean = false) => {
const endpointHost = httpMocks.responseProvider.metadataDetails();
endpointHost = httpMocks.responseProvider.metadataDetails();
// Safe to mutate this mocked data
// @ts-expect-error TS2540
endpointHost.metadata.Endpoint.state.isolation = isolation;
@ -60,7 +62,8 @@ describe('When using the Endpoint Details Actions Menu', () => {
(useKibana as jest.Mock).mockReturnValue({ services: mockedContext.startServices });
coreStart = mockedContext.coreStart;
waitForAction = mockedContext.middlewareSpy.waitForAction;
middlewareSpy = mockedContext.middlewareSpy;
httpMocks = endpointPageHttpMock(mockedContext.coreStart.http);
(useUserPrivileges as jest.Mock).mockReturnValue(getUserPrivilegesMockDefaultValue());
@ -72,15 +75,10 @@ describe('When using the Endpoint Details Actions Menu', () => {
});
render = async () => {
renderResult = mockedContext.render(<ActionsMenu />);
await act(async () => {
await waitForAction('serverReturnedEndpointDetails');
});
act(() => {
fireEvent.click(renderResult.getByTestId('endpointDetailsActionsButton'));
});
renderResult = mockedContext.render(<ActionsMenu hostMetadata={endpointHost?.metadata} />);
const endpointDetailsActionsButton = renderResult.getByTestId('endpointDetailsActionsButton');
endpointDetailsActionsButton.style.pointerEvents = 'all';
userEvent.click(endpointDetailsActionsButton);
return renderResult;
};
@ -119,10 +117,14 @@ describe('When using the Endpoint Details Actions Menu', () => {
'should navigate via kibana `navigateToApp()` when %s is clicked',
async (_, dataTestSubj) => {
await render();
act(() => {
fireEvent.click(renderResult.getByTestId(dataTestSubj));
await act(async () => {
await middlewareSpy.waitForAction('serverReturnedEndpointAgentPolicies');
});
const takeActionMenuItem = renderResult.getByTestId(dataTestSubj);
takeActionMenuItem.style.pointerEvents = 'all';
userEvent.click(takeActionMenuItem);
expect(coreStart.application.navigateToApp).toHaveBeenCalled();
}
);
@ -132,16 +134,16 @@ describe('When using the Endpoint Details Actions Menu', () => {
beforeEach(() => setEndpointMetadataResponse(true));
describe('and user has unisolate privilege', () => {
it('should display Unisolate action', async () => {
it('should display `Release` action', async () => {
await render();
expect(renderResult.getByTestId('unIsolateLink')).not.toBeNull();
});
it('should navigate via router when unisolate is clicked', async () => {
it('should navigate via router when `Release` is clicked', async () => {
await render();
act(() => {
fireEvent.click(renderResult.getByTestId('unIsolateLink'));
});
const isolateButton = renderResult.getByTestId('unIsolateLink');
isolateButton.style.pointerEvents = 'all';
userEvent.click(isolateButton);
expect(coreStart.application.navigateToApp).toHaveBeenCalled();
});
@ -159,7 +161,7 @@ describe('When using the Endpoint Details Actions Menu', () => {
});
});
it('should not display unisolate action', async () => {
it('should not display `Release` action', async () => {
await render();
expect(renderResult.queryByTestId('unIsolateLink')).toBeNull();
});
@ -173,7 +175,7 @@ describe('When using the Endpoint Details Actions Menu', () => {
afterEach(() => licenseServiceMock.isPlatinumPlus.mockReturnValue(true));
it('should still show `unisolate` action for endpoints that are currently isolated', async () => {
it('should still show `Release` action for endpoints that are currently isolated', async () => {
setEndpointMetadataResponse(true);
await render();
expect(renderResult.queryByTestId('isolateLink')).toBeNull();

View file

@ -5,16 +5,15 @@
* 2.0.
*/
import React, { useState, useCallback, useMemo } from 'react';
import { EuiContextMenuPanel, EuiButton, EuiPopover } from '@elastic/eui';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { EuiButton, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { useEndpointActionItems, useEndpointSelector } from '../../hooks';
import { fullDetailsHostInfo } from '../../../store/selectors';
import type { HostMetadata } from '../../../../../../../common/endpoint/types';
import { useEndpointActionItems } from '../../hooks';
import { ContextMenuItemNavByRouter } from '../../../../../components/context_menu_with_router_support/context_menu_item_nav_by_router';
export const ActionsMenu = React.memo<{}>(() => {
const endpointDetails = useEndpointSelector(fullDetailsHostInfo);
const menuOptions = useEndpointActionItems(endpointDetails?.metadata);
export const ActionsMenu = memo<{ hostMetadata: HostMetadata }>(({ hostMetadata }) => {
const menuOptions = useEndpointActionItems(hostMetadata);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const closePopoverHandler = useCallback(() => {

View file

@ -6,7 +6,7 @@
*/
import React, { memo, useMemo } from 'react';
import { EuiTab, EuiTabs, EuiFlyoutBody } from '@elastic/eui';
import { EuiFlyoutBody, EuiTab, EuiTabs } from '@elastic/eui';
import type { EndpointIndexUIQueryParams } from '../../../types';
import { EndpointDetailsFlyoutHeader } from './flyout_header';
@ -45,16 +45,15 @@ const EndpointDetailsTab = memo(
EndpointDetailsTab.displayName = 'EndpointDetailsTab';
export const EndpointDetailsFlyoutTabs = memo(
({
hostname,
show,
tabs,
}: {
hostname: string;
show: EndpointIndexUIQueryParams['show'];
tabs: EndpointDetailsTabs[];
}) => {
interface EndpointDetailsTabsProps {
hostname: string;
isHostInfoLoading: boolean;
show: EndpointIndexUIQueryParams['show'];
tabs: EndpointDetailsTabs[];
}
export const EndpointDetailsFlyoutTabs = memo<EndpointDetailsTabsProps>(
({ hostname, isHostInfoLoading, show, tabs }) => {
const selectedTab = useMemo(() => tabs.find((tab) => tab.id === show), [tabs, show]);
const renderTabs = tabs.map((tab) => (
@ -63,7 +62,11 @@ export const EndpointDetailsFlyoutTabs = memo(
return (
<>
<EndpointDetailsFlyoutHeader hostname={hostname} hasBorder>
<EndpointDetailsFlyoutHeader
hostname={hostname}
isHostInfoLoading={isHostInfoLoading}
hasBorder
>
<EuiTabs bottomBorder={false} style={{ marginBottom: '-25px' }}>
{renderTabs}
</EuiTabs>

View file

@ -9,24 +9,24 @@ import React, { memo, useCallback, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import type { Dispatch } from 'redux';
import { EuiForm, EuiFlyoutBody } from '@elastic/eui';
import { EuiFlyoutBody, EuiForm } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { isEndpointHostIsolated } from '../../../../../../common/utils/validators';
import type { HostMetadata } from '../../../../../../../common/endpoint/types';
import type { EndpointIsolatedFormProps } from '../../../../../../common/components/endpoint/host_isolation';
import {
ActionCompletionReturnButton,
EndpointIsolateForm,
EndpointIsolateSuccess,
EndpointUnisolateForm,
ActionCompletionReturnButton,
} from '../../../../../../common/components/endpoint/host_isolation';
import { getEndpointDetailsPath } from '../../../../../common/routing';
import { useEndpointSelector } from '../../hooks';
import {
getIsolationRequestError,
getIsIsolationRequestPending,
getIsolationRequestError,
getWasIsolationRequestSuccessful,
uiQueryParams,
getIsEndpointHostIsolated,
} from '../../../store/selectors';
import type { AppAction } from '../../../../../../common/store/actions';
@ -40,7 +40,7 @@ export const EndpointIsolationFlyoutPanel = memo<{
const dispatch = useDispatch<Dispatch<AppAction>>();
const { show, ...queryParams } = useEndpointSelector(uiQueryParams);
const isCurrentlyIsolated = useEndpointSelector(getIsEndpointHostIsolated);
const isCurrentlyIsolated = isEndpointHostIsolated(hostMeta);
const isPending = useEndpointSelector(getIsIsolationRequestPending);
const wasSuccessful = useEndpointSelector(getWasIsolationRequestSuccessful);
const isolateError = useEndpointSelector(getIsolationRequestError);

View file

@ -7,29 +7,23 @@
import React, { memo } from 'react';
import { EuiFlyoutHeader, EuiSkeletonText, EuiTitle, EuiToolTip } from '@elastic/eui';
import { useEndpointSelector } from '../../hooks';
import { isHostInfoLoading } from '../../../store/selectors';
import { BackToEndpointDetailsFlyoutSubHeader } from './back_to_endpoint_details_flyout_subheader';
export const EndpointDetailsFlyoutHeader = memo(
({
endpointId,
hasBorder = false,
hostname,
children,
}: {
endpointId?: string;
hasBorder?: boolean;
hostname?: string;
children?: React.ReactNode | React.ReactNode[];
}) => {
const hostDetailsLoading = useEndpointSelector(isHostInfoLoading);
interface EndpointDetailsFlyoutHeaderProps {
children?: React.ReactNode | React.ReactNode[];
endpointId?: string;
hasBorder?: boolean;
hostname?: string;
isHostInfoLoading: boolean;
}
export const EndpointDetailsFlyoutHeader = memo<EndpointDetailsFlyoutHeaderProps>(
({ children, endpointId, hasBorder = false, hostname, isHostInfoLoading }) => {
return (
<EuiFlyoutHeader hasBorder={hasBorder}>
{endpointId && <BackToEndpointDetailsFlyoutSubHeader endpointId={endpointId} />}
{hostDetailsLoading ? (
{isHostInfoLoading ? (
<EuiSkeletonText lines={1} />
) : (
<EuiToolTip content={hostname} anchorClassName="eui-textTruncate">

View file

@ -7,19 +7,14 @@
import { EuiFlyoutBody, EuiFlyoutFooter, EuiSkeletonText, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { memo, useCallback, useEffect, useMemo } from 'react';
import { useGetEndpointDetails } from '../../../../hooks';
import { useUserPrivileges } from '../../../../../common/components/user_privileges';
import { ResponseActionsLog } from '../../../../components/endpoint_response_actions_list/response_actions_log';
import { PolicyResponseWrapper } from '../../../../components/policy_response';
import type { HostMetadata } from '../../../../../../common/endpoint/types';
import { useToasts } from '../../../../../common/lib/kibana';
import { getEndpointDetailsPath } from '../../../../common/routing';
import {
fullDetailsHostInfo,
hostInfoError,
policyVersionInfo,
showView,
uiQueryParams,
} from '../../store/selectors';
import { showView, uiQueryParams } from '../../store/selectors';
import { useEndpointSelector } from '../hooks';
import * as i18 from '../translations';
import { ActionsMenu } from './components/actions_menu';
@ -36,14 +31,16 @@ export const EndpointDetails = memo(() => {
const toasts = useToasts();
const queryParams = useEndpointSelector(uiQueryParams);
const hostInfo = useEndpointSelector(fullDetailsHostInfo);
const hostDetailsError = useEndpointSelector(hostInfoError);
const {
data: hostInfo,
error: hostInfoError,
isFetching: isHostInfoLoading,
} = useGetEndpointDetails(queryParams.selected_endpoint ?? '');
const policyInfo = useEndpointSelector(policyVersionInfo);
const show = useEndpointSelector(showView);
const { canAccessEndpointActionsLogManagement } = useUserPrivileges().endpointPrivileges;
const ContentLoadingMarkup = useMemo(
const contentLoadingMarkup = useMemo(
() => (
<>
<EuiSkeletonText lines={3} />
@ -67,9 +64,9 @@ export const EndpointDetails = memo(() => {
}),
content:
hostInfo === undefined ? (
ContentLoadingMarkup
contentLoadingMarkup
) : (
<EndpointDetailsContent hostInfo={hostInfo} policyInfo={policyInfo} />
<EndpointDetailsContent hostInfo={hostInfo} policyInfo={hostInfo.policy_info} />
),
},
];
@ -90,14 +87,14 @@ export const EndpointDetails = memo(() => {
}
return tabs;
},
[canAccessEndpointActionsLogManagement, ContentLoadingMarkup, hostInfo, policyInfo, queryParams]
[canAccessEndpointActionsLogManagement, contentLoadingMarkup, hostInfo, queryParams]
);
const showFlyoutFooter =
show === 'details' || show === 'policy_response' || show === 'activity_log';
useEffect(() => {
if (hostDetailsError !== undefined) {
if (hostInfoError !== null) {
toasts.addDanger({
title: i18n.translate('xpack.securitySolution.endpoint.details.errorTitle', {
defaultMessage: 'Could not find host',
@ -107,15 +104,16 @@ export const EndpointDetails = memo(() => {
}),
});
}
}, [hostDetailsError, show, toasts]);
}, [hostInfoError, show, toasts]);
return (
<>
{(show === 'policy_response' || show === 'isolate' || show === 'unisolate') && (
<EndpointDetailsFlyoutHeader
hasBorder
endpointId={hostInfo?.metadata?.agent.id}
hasBorder
hostname={hostInfo?.metadata?.host?.hostname}
isHostInfoLoading={isHostInfoLoading}
/>
)}
{hostInfo === undefined ? (
@ -127,6 +125,7 @@ export const EndpointDetails = memo(() => {
{(show === 'details' || show === 'activity_log') && (
<EndpointDetailsFlyoutTabs
hostname={hostInfo.metadata.host.hostname}
isHostInfoLoading={isHostInfoLoading}
// show overview tab if forcing response actions history
// tab via URL without permission
show={!canAccessEndpointActionsLogManagement ? 'details' : show}
@ -142,7 +141,7 @@ export const EndpointDetails = memo(() => {
{showFlyoutFooter && (
<EuiFlyoutFooter className="eui-textRight" data-test-subj="endpointDetailsFlyoutFooter">
<ActionsMenu />
<ActionsMenu hostMetadata={hostInfo.metadata} />
</EuiFlyoutFooter>
)}
</>

View file

@ -24,7 +24,6 @@ import { useEndpointSelector } from '../hooks';
import {
getEndpointPendingActionsCallback,
nonExistingPolicies,
policyResponseStatus,
uiQueryParams,
} from '../../store/selectors';
import { POLICY_STATUS_TO_BADGE_COLOR } from '../host_constants';
@ -64,9 +63,10 @@ interface EndpointDetailsContentProps {
export const EndpointDetailsContent = memo<EndpointDetailsContentProps>(
({ hostInfo, policyInfo }) => {
const queryParams = useEndpointSelector(uiQueryParams);
const policyStatus = useEndpointSelector(
policyResponseStatus
) as keyof typeof POLICY_STATUS_TO_BADGE_COLOR;
const policyStatus = useMemo(
() => hostInfo.metadata.Endpoint.policy.applied.status,
[hostInfo]
);
const getHostPendingActions = useEndpointSelector(getEndpointPendingActionsCallback);
const missingPolicies = useEndpointSelector(nonExistingPolicies);

View file

@ -27,6 +27,7 @@ interface Options {
/**
* Returns a list (array) of actions for an individual endpoint
* @param endpointMetadata
* @param options
*/
export const useEndpointActionItems = (
endpointMetadata: MaybeImmutable<HostMetadata> | undefined,
@ -45,218 +46,214 @@ export const useEndpointActionItems = (
} = useUserPrivileges().endpointPrivileges;
return useMemo<ContextMenuItemNavByRouterProps[]>(() => {
if (endpointMetadata) {
const isIsolated = isEndpointHostIsolated(endpointMetadata);
const endpointId = endpointMetadata.agent.id;
const endpointPolicyId = endpointMetadata.Endpoint.policy.applied.id;
const endpointHostName = endpointMetadata.host.hostname;
const fleetAgentId = endpointMetadata.elastic.agent.id;
const isolationSupported = isIsolationSupported({
osName: endpointMetadata.host.os.name,
version: endpointMetadata.agent.version,
capabilities: endpointMetadata.Endpoint.capabilities,
});
const {
show,
selected_endpoint: _selectedEndpoint,
...currentUrlParams
} = allCurrentUrlParams;
const endpointActionsPath = getEndpointDetailsPath({
name: 'endpointActivityLog',
...currentUrlParams,
selected_endpoint: endpointId,
});
const endpointIsolatePath = getEndpointDetailsPath({
name: 'endpointIsolate',
...currentUrlParams,
selected_endpoint: endpointId,
});
const endpointUnIsolatePath = getEndpointDetailsPath({
name: 'endpointUnIsolate',
...currentUrlParams,
selected_endpoint: endpointId,
});
if (!endpointMetadata) {
return [];
}
const isolationActions = [];
const isIsolated = isEndpointHostIsolated(endpointMetadata);
const endpointId = endpointMetadata.agent.id;
const endpointPolicyId = endpointMetadata.Endpoint.policy.applied.id;
const endpointHostName = endpointMetadata.host.hostname;
const fleetAgentId = endpointMetadata.elastic.agent.id;
const isolationSupported = isIsolationSupported({
osName: endpointMetadata.host.os.name,
version: endpointMetadata.agent.version,
capabilities: endpointMetadata.Endpoint.capabilities,
});
const { show, selected_endpoint: _selectedEndpoint, ...currentUrlParams } = allCurrentUrlParams;
const endpointActionsPath = getEndpointDetailsPath({
name: 'endpointActivityLog',
...currentUrlParams,
selected_endpoint: endpointId,
});
const endpointIsolatePath = getEndpointDetailsPath({
name: 'endpointIsolate',
...currentUrlParams,
selected_endpoint: endpointId,
});
const endpointUnIsolatePath = getEndpointDetailsPath({
name: 'endpointUnIsolate',
...currentUrlParams,
selected_endpoint: endpointId,
});
if (isIsolated && canUnIsolateHost) {
// Un-isolate is available to users regardless of license level if they have unisolate permissions
isolationActions.push({
'data-test-subj': 'unIsolateLink',
icon: 'lockOpen',
key: 'unIsolateHost',
navigateAppId: APP_UI_ID,
navigateOptions: {
path: endpointUnIsolatePath,
},
href: getAppUrl({ path: endpointUnIsolatePath }),
children: (
<FormattedMessage
id="xpack.securitySolution.endpoint.actions.unIsolateHost"
defaultMessage="Release host"
/>
),
});
} else if (isolationSupported && canIsolateHost) {
// For Platinum++ licenses, users also have ability to isolate
isolationActions.push({
'data-test-subj': 'isolateLink',
icon: 'lock',
key: 'isolateHost',
navigateAppId: APP_UI_ID,
navigateOptions: {
path: endpointIsolatePath,
},
href: getAppUrl({ path: endpointIsolatePath }),
children: (
<FormattedMessage
id="xpack.securitySolution.endpoint.actions.isolateHost"
defaultMessage="Isolate host"
/>
),
});
}
const isolationActions = [];
return [
...isolationActions,
...(canAccessResponseConsole
? [
{
'data-test-subj': 'console',
icon: 'console',
key: 'consoleLink',
onClick: (ev: React.MouseEvent) => {
ev.preventDefault();
showEndpointResponseActionsConsole(endpointMetadata);
},
children: (
<FormattedMessage
id="xpack.securitySolution.endpoint.actions.console"
defaultMessage="Respond"
/>
),
},
]
: []),
...(options?.isEndpointList && canAccessEndpointActionsLogManagement
? [
{
'data-test-subj': 'actionsLink',
icon: 'logoSecurity',
key: 'actionsLogLink',
navigateAppId: APP_UI_ID,
navigateOptions: { path: endpointActionsPath },
href: getAppUrl({ path: endpointActionsPath }),
children: (
<FormattedMessage
id="xpack.securitySolution.endpoint.actions.responseActionsHistory"
defaultMessage="View response actions history"
/>
),
},
]
: []),
{
'data-test-subj': 'hostLink',
icon: 'logoSecurity',
key: 'hostDetailsLink',
navigateAppId: APP_UI_ID,
navigateOptions: { path: `/hosts/${endpointHostName}` },
href: getAppUrl({ path: `/hosts/${endpointHostName}` }),
children: (
<FormattedMessage
id="xpack.securitySolution.endpoint.actions.hostDetails"
defaultMessage="View host details"
/>
),
if (isIsolated && canUnIsolateHost) {
// Un-isolate is available to users regardless of license level if they have unisolate permissions
isolationActions.push({
'data-test-subj': 'unIsolateLink',
icon: 'lockOpen',
key: 'unIsolateHost',
navigateAppId: APP_UI_ID,
navigateOptions: {
path: endpointUnIsolatePath,
},
...(canAccessFleet
? [
{
icon: 'gear',
key: 'agentConfigLink',
'data-test-subj': 'agentPolicyLink',
navigateAppId: 'fleet',
navigateOptions: {
path: `${
pagePathGetters.policy_details({
policyId: fleetAgentPolicies[endpointPolicyId],
})[1]
}`,
},
href: `${getAppUrl({ appId: 'fleet' })}${
href: getAppUrl({ path: endpointUnIsolatePath }),
children: (
<FormattedMessage
id="xpack.securitySolution.endpoint.actions.unIsolateHost"
defaultMessage="Release host"
/>
),
});
} else if (isolationSupported && canIsolateHost) {
// For Platinum++ licenses, users also have ability to isolate
isolationActions.push({
'data-test-subj': 'isolateLink',
icon: 'lock',
key: 'isolateHost',
navigateAppId: APP_UI_ID,
navigateOptions: {
path: endpointIsolatePath,
},
href: getAppUrl({ path: endpointIsolatePath }),
children: (
<FormattedMessage
id="xpack.securitySolution.endpoint.actions.isolateHost"
defaultMessage="Isolate host"
/>
),
});
}
return [
...isolationActions,
...(canAccessResponseConsole
? [
{
'data-test-subj': 'console',
icon: 'console',
key: 'consoleLink',
onClick: (ev: React.MouseEvent) => {
ev.preventDefault();
showEndpointResponseActionsConsole(endpointMetadata);
},
children: (
<FormattedMessage
id="xpack.securitySolution.endpoint.actions.console"
defaultMessage="Respond"
/>
),
},
]
: []),
...(options?.isEndpointList && canAccessEndpointActionsLogManagement
? [
{
'data-test-subj': 'actionsLink',
icon: 'logoSecurity',
key: 'actionsLogLink',
navigateAppId: APP_UI_ID,
navigateOptions: { path: endpointActionsPath },
href: getAppUrl({ path: endpointActionsPath }),
children: (
<FormattedMessage
id="xpack.securitySolution.endpoint.actions.responseActionsHistory"
defaultMessage="View response actions history"
/>
),
},
]
: []),
{
'data-test-subj': 'hostLink',
icon: 'logoSecurity',
key: 'hostDetailsLink',
navigateAppId: APP_UI_ID,
navigateOptions: { path: `/hosts/${endpointHostName}` },
href: getAppUrl({ path: `/hosts/${endpointHostName}` }),
children: (
<FormattedMessage
id="xpack.securitySolution.endpoint.actions.hostDetails"
defaultMessage="View host details"
/>
),
},
...(canAccessFleet
? [
{
icon: 'gear',
key: 'agentConfigLink',
'data-test-subj': 'agentPolicyLink',
navigateAppId: 'fleet',
navigateOptions: {
path: `${
pagePathGetters.policy_details({
policyId: fleetAgentPolicies[endpointPolicyId],
})[1]
}`,
disabled: fleetAgentPolicies[endpointPolicyId] === undefined,
children: (
<FormattedMessage
id="xpack.securitySolution.endpoint.actions.agentPolicy"
defaultMessage="View agent policy"
/>
),
},
{
icon: 'gear',
key: 'agentDetailsLink',
'data-test-subj': 'agentDetailsLink',
navigateAppId: 'fleet',
navigateOptions: {
path: `${
pagePathGetters.agent_details({
agentId: fleetAgentId,
})[1]
}`,
},
href: `${getAppUrl({ appId: 'fleet' })}${
href: `${getAppUrl({ appId: 'fleet' })}${
pagePathGetters.policy_details({
policyId: fleetAgentPolicies[endpointPolicyId],
})[1]
}`,
disabled: fleetAgentPolicies[endpointPolicyId] === undefined,
children: (
<FormattedMessage
id="xpack.securitySolution.endpoint.actions.agentPolicy"
defaultMessage="View agent policy"
/>
),
},
{
icon: 'gear',
key: 'agentDetailsLink',
'data-test-subj': 'agentDetailsLink',
navigateAppId: 'fleet',
navigateOptions: {
path: `${
pagePathGetters.agent_details({
agentId: fleetAgentId,
})[1]
}`,
children: (
<FormattedMessage
id="xpack.securitySolution.endpoint.actions.agentDetails"
defaultMessage="View agent details"
/>
),
},
{
icon: 'gear',
key: 'agentPolicyReassignLink',
'data-test-subj': 'agentPolicyReassignLink',
navigateAppId: 'fleet',
navigateOptions: {
path: `${
pagePathGetters.agent_details({
agentId: fleetAgentId,
})[1]
}?openReassignFlyout=true`,
state: {
onDoneNavigateTo: [
APP_UI_ID,
{ path: getEndpointListPath({ name: 'endpointList' }) },
],
},
},
href: `${getAppUrl({ appId: 'fleet' })}${
href: `${getAppUrl({ appId: 'fleet' })}${
pagePathGetters.agent_details({
agentId: fleetAgentId,
})[1]
}`,
children: (
<FormattedMessage
id="xpack.securitySolution.endpoint.actions.agentDetails"
defaultMessage="View agent details"
/>
),
},
{
icon: 'gear',
key: 'agentPolicyReassignLink',
'data-test-subj': 'agentPolicyReassignLink',
navigateAppId: 'fleet',
navigateOptions: {
path: `${
pagePathGetters.agent_details({
agentId: fleetAgentId,
})[1]
}?openReassignFlyout=true`,
children: (
<FormattedMessage
id="xpack.securitySolution.endpoint.actions.agentPolicyReassign"
defaultMessage="Reassign agent policy"
/>
),
state: {
onDoneNavigateTo: [
APP_UI_ID,
{ path: getEndpointListPath({ name: 'endpointList' }) },
],
},
},
]
: []),
];
}
return [];
href: `${getAppUrl({ appId: 'fleet' })}${
pagePathGetters.agent_details({
agentId: fleetAgentId,
})[1]
}?openReassignFlyout=true`,
children: (
<FormattedMessage
id="xpack.securitySolution.endpoint.actions.agentPolicyReassign"
defaultMessage="Reassign agent policy"
/>
),
},
]
: []),
];
}, [
allCurrentUrlParams,
canAccessResponseConsole,

View file

@ -19,11 +19,7 @@ import {
} from '../store/mock_endpoint_result_list';
import type { AppContextTestRender } from '../../../../common/mock/endpoint';
import { createAppRootMockRenderer } from '../../../../common/mock/endpoint';
import type {
HostInfo,
HostPolicyResponse,
HostPolicyResponseAppliedAction,
} from '../../../../../common/endpoint/types';
import type { HostInfo, HostPolicyResponse } from '../../../../../common/endpoint/types';
import { HostPolicyResponseActionStatus, HostStatus } from '../../../../../common/endpoint/types';
import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data';
import { POLICY_STATUS_TO_HEALTH_COLOR, POLICY_STATUS_TO_TEXT } from './host_constants';
@ -59,6 +55,7 @@ import {
import { getUserPrivilegesMockDefaultValue } from '../../../../common/components/user_privileges/__mocks__';
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';
const mockUserPrivileges = useUserPrivileges as jest.Mock;
// not sure why this can't be imported from '../../../../common/mock/formatted_relative';
@ -138,6 +135,8 @@ const timepickerRanges = [
jest.mock('../../../../common/lib/kibana');
jest.mock('../../../../common/hooks/use_license');
jest.mock('../../../hooks/endpoint/use_get_endpoint_details');
const mockUseGetEndpointDetails = useGetEndpointDetails as jest.Mock;
describe('when on the endpoint list page', () => {
const docGenerator = new EndpointDocGenerator();
@ -407,8 +406,29 @@ describe('when on the endpoint list page', () => {
});
describe('when the user clicks the first hostname in the table', () => {
const endpointDetails: HostInfo = mockEndpointDetailsApiResult();
let renderResult: reactTestingLibrary.RenderResult;
beforeEach(async () => {
mockUseGetEndpointDetails.mockReturnValue({
data: {
...endpointDetails,
host_status: endpointDetails.host_status,
metadata: {
...endpointDetails.metadata,
Endpoint: {
...endpointDetails.metadata.Endpoint,
state: {
...endpointDetails.metadata.Endpoint.state,
isolation: false,
},
},
agent: {
...endpointDetails.metadata.agent,
id: '1',
},
},
},
});
renderResult = render();
await reactTestingLibrary.act(async () => {
await middlewareSpy.waitForAction('serverReturnedEndpointList');
@ -487,13 +507,13 @@ describe('when on the endpoint list page', () => {
describe('when there is a selected host in the url', () => {
let hostInfo: HostInfo;
let renderAndWaitForData: () => Promise<ReturnType<AppContextTestRender['render']>>;
const endpointDetails: HostInfo = mockEndpointDetailsApiResult();
const mockEndpointListApi = (mockedPolicyResponse?: HostPolicyResponse) => {
const {
host_status: hostStatus,
last_checkin: lastCheckin,
metadata: { agent, Endpoint, ...details },
} = mockEndpointDetailsApiResult();
} = endpointDetails;
hostInfo = {
host_status: hostStatus,
@ -524,83 +544,35 @@ describe('when on the endpoint list page', () => {
});
};
const createPolicyResponse = (
overallStatus: HostPolicyResponseActionStatus = HostPolicyResponseActionStatus.success
): HostPolicyResponse => {
const policyResponse = docGenerator.generatePolicyResponse();
const malwareResponseConfigurations =
policyResponse.Endpoint.policy.applied.response.configurations.malware;
policyResponse.Endpoint.policy.applied.status = overallStatus;
malwareResponseConfigurations.status = overallStatus;
let downloadModelAction = policyResponse.Endpoint.policy.applied.actions.find(
(action) => action.name === 'download_model'
);
if (!downloadModelAction) {
downloadModelAction = {
name: 'download_model',
message: 'Failed to apply a portion of the configuration (kernel)',
status: overallStatus,
};
policyResponse.Endpoint.policy.applied.actions.push(downloadModelAction);
} else {
// Else, make sure the status of the generated action matches what was passed in
downloadModelAction.status = overallStatus;
}
if (
overallStatus === HostPolicyResponseActionStatus.failure ||
overallStatus === HostPolicyResponseActionStatus.warning
) {
downloadModelAction.message = 'no action taken';
}
// Make sure that at least one configuration has the above action, else
// we get into an out-of-sync condition
if (
malwareResponseConfigurations.concerned_actions.indexOf(downloadModelAction.name) === -1
) {
malwareResponseConfigurations.concerned_actions.push(downloadModelAction.name);
}
// Add an unknown Action Name - to ensure we handle the format of it on the UI
const unknownAction: HostPolicyResponseAppliedAction = {
status: HostPolicyResponseActionStatus.success,
message: 'test message',
name: 'a_new_unknown_action',
};
policyResponse.Endpoint.policy.applied.actions.push(unknownAction);
malwareResponseConfigurations.concerned_actions.push(unknownAction.name);
return policyResponse;
};
const dispatchServerReturnedEndpointPolicyResponse = (
overallStatus: HostPolicyResponseActionStatus = HostPolicyResponseActionStatus.success
) => {
reactTestingLibrary.act(() => {
store.dispatch({
type: 'serverReturnedEndpointPolicyResponse',
payload: {
policy_response: createPolicyResponse(overallStatus),
const getMockUseEndpointDetails = (policyStatus?: HostPolicyResponseActionStatus) => {
return mockUseGetEndpointDetails.mockReturnValue({
data: {
...hostInfo,
metadata: {
...hostInfo.metadata,
Endpoint: {
...hostInfo.metadata.Endpoint,
policy: {
...hostInfo.metadata.Endpoint.policy,
applied: {
...hostInfo.metadata.Endpoint.policy.applied,
status: policyStatus,
},
},
},
},
});
},
});
};
beforeEach(async () => {
mockEndpointListApi();
getMockUseEndpointDetails();
mockUserPrivileges.mockReturnValue(getUserPrivilegesMockDefaultValue());
reactTestingLibrary.act(() => {
history.push(`${MANAGEMENT_PATH}/endpoints?selected_endpoint=1`);
});
renderAndWaitForData = async () => {
const renderResult = render();
await middlewareSpy.waitForAction('serverReturnedEndpointDetails');
return renderResult;
};
});
afterEach(() => {
@ -609,13 +581,13 @@ describe('when on the endpoint list page', () => {
});
it('should show the flyout and footer', async () => {
const renderResult = await renderAndWaitForData();
const renderResult = render();
expect(renderResult.getByTestId('endpointDetailsFlyout')).not.toBeNull();
expect(renderResult.getByTestId('endpointDetailsFlyoutFooter')).not.toBeNull();
});
it('should display policy name value as a link', async () => {
const renderResult = await renderAndWaitForData();
const renderResult = render();
const policyDetailsLink = await renderResult.findByTestId('policyDetailsValue');
expect(policyDetailsLink).not.toBeNull();
expect(policyDetailsLink.getAttribute('href')).toEqual(
@ -624,7 +596,7 @@ describe('when on the endpoint list page', () => {
});
it('should display policy revision number', async () => {
const renderResult = await renderAndWaitForData();
const renderResult = render();
const policyDetailsRevElement = await renderResult.findByTestId('policyDetailsRevNo');
expect(policyDetailsRevElement).not.toBeNull();
expect(policyDetailsRevElement.textContent).toEqual(
@ -633,7 +605,7 @@ describe('when on the endpoint list page', () => {
});
it('should update the URL when policy name link is clicked', async () => {
const renderResult = await renderAndWaitForData();
const renderResult = render();
const policyDetailsLink = await renderResult.findByTestId('policyDetailsValue');
const userChangedUrlChecker = middlewareSpy.waitForAction('userChangedUrl');
reactTestingLibrary.act(() => {
@ -646,7 +618,7 @@ describe('when on the endpoint list page', () => {
});
it('should update the URL when policy status link is clicked', async () => {
const renderResult = await renderAndWaitForData();
const renderResult = render();
const policyStatusLink = await renderResult.findByTestId('policyStatusValue');
const userChangedUrlChecker = middlewareSpy.waitForAction('userChangedUrl');
reactTestingLibrary.act(() => {
@ -659,38 +631,39 @@ describe('when on the endpoint list page', () => {
});
it('should display Success overall policy status', async () => {
const renderResult = await renderAndWaitForData();
getMockUseEndpointDetails(HostPolicyResponseActionStatus.success);
const renderResult = render();
const policyStatusBadge = await renderResult.findByTestId('policyStatusValue');
expect(renderResult.getByTestId('policyStatusValue-success')).toBeTruthy();
expect(policyStatusBadge.textContent).toEqual('Success');
});
it('should display Warning overall policy status', async () => {
mockEndpointListApi(createPolicyResponse(HostPolicyResponseActionStatus.warning));
const renderResult = await renderAndWaitForData();
getMockUseEndpointDetails(HostPolicyResponseActionStatus.warning);
const renderResult = render();
const policyStatusBadge = await renderResult.findByTestId('policyStatusValue');
expect(policyStatusBadge.textContent).toEqual('Warning');
expect(renderResult.getByTestId('policyStatusValue-warning')).toBeTruthy();
});
it('should display Failed overall policy status', async () => {
mockEndpointListApi(createPolicyResponse(HostPolicyResponseActionStatus.failure));
const renderResult = await renderAndWaitForData();
getMockUseEndpointDetails(HostPolicyResponseActionStatus.failure);
const renderResult = render();
const policyStatusBadge = await renderResult.findByTestId('policyStatusValue');
expect(policyStatusBadge.textContent).toEqual('Failed');
expect(renderResult.getByTestId('policyStatusValue-failure')).toBeTruthy();
});
it('should display Unknown overall policy status', async () => {
mockEndpointListApi(createPolicyResponse('' as HostPolicyResponseActionStatus));
const renderResult = await renderAndWaitForData();
getMockUseEndpointDetails('' as HostPolicyResponseActionStatus);
const renderResult = render();
const policyStatusBadge = await renderResult.findByTestId('policyStatusValue');
expect(policyStatusBadge.textContent).toEqual('Unknown');
expect(renderResult.getByTestId('policyStatusValue-')).toBeTruthy();
});
it('should show the Take Action button', async () => {
const renderResult = await renderAndWaitForData();
const renderResult = render();
expect(renderResult.getByTestId('endpointDetailsActionsButton')).not.toBeNull();
});
@ -711,7 +684,7 @@ describe('when on the endpoint list page', () => {
describe('when `canReadActionsLogManagement` is TRUE', () => {
it('should start with the activity log tab as unselected', async () => {
const renderResult = await renderAndWaitForData();
const renderResult = await render();
const detailsTab = renderResult.getByTestId('endpoint-details-flyout-tab-details');
const activityLogTab = renderResult.getByTestId(
'endpoint-details-flyout-tab-activity_log'
@ -724,7 +697,7 @@ describe('when on the endpoint list page', () => {
});
it('should show the activity log content when selected', async () => {
const renderResult = await renderAndWaitForData();
const renderResult = await render();
const detailsTab = renderResult.getByTestId('endpoint-details-flyout-tab-details');
const activityLogTab = renderResult.getByTestId(
'endpoint-details-flyout-tab-activity_log'
@ -749,7 +722,7 @@ describe('when on the endpoint list page', () => {
canAccessFleet: true,
},
});
const renderResult = await renderAndWaitForData();
const renderResult = await render();
const detailsTab = renderResult.getByTestId('endpoint-details-flyout-tab-details');
const activityLogTab = renderResult.queryByTestId(
'endpoint-details-flyout-tab-activity_log'
@ -774,7 +747,7 @@ describe('when on the endpoint list page', () => {
history.push(`${MANAGEMENT_PATH}/endpoints?selected_endpoint=1&show=activity_log`);
});
const renderResult = await renderAndWaitForData();
const renderResult = await render();
const detailsTab = renderResult.getByTestId('endpoint-details-flyout-tab-details');
const activityLogTab = renderResult.queryByTestId(
'endpoint-details-flyout-tab-activity_log'
@ -796,17 +769,13 @@ describe('when on the endpoint list page', () => {
}
throw new Error(`POST to '${requestOptions.path}' does not have a mock response!`);
});
renderResult = await renderAndWaitForData();
renderResult = await render();
const policyStatusLink = await renderResult.findByTestId('policyStatusValue');
const userChangedUrlChecker = middlewareSpy.waitForAction('userChangedUrl');
reactTestingLibrary.act(() => {
reactTestingLibrary.fireEvent.click(policyStatusLink);
});
await userChangedUrlChecker;
await middlewareSpy.waitForAction('serverReturnedEndpointPolicyResponse');
reactTestingLibrary.act(() => {
dispatchServerReturnedEndpointPolicyResponse();
});
});
afterEach(reactTestingLibrary.cleanup);
@ -876,7 +845,7 @@ describe('when on the endpoint list page', () => {
reactTestingLibrary.act(() => {
history.push(`${MANAGEMENT_PATH}/endpoints?selected_endpoint=1&show=isolate`);
});
renderResult = await renderAndWaitForData();
renderResult = render();
// Need to reset `http.post` and adjust it so that the mock for http host
// isolation api does not output error noise to the console
coreStart.http.post.mockReset();
@ -1254,6 +1223,7 @@ describe('when on the endpoint list page', () => {
expect(banner).toHaveTextContent(transforms[1].id);
});
});
describe('endpoint list onboarding screens with RBAC', () => {
beforeEach(() => {
setEndpointListApiMockImplementation(coreStart.http, {
@ -1314,6 +1284,7 @@ describe('when on the endpoint list page', () => {
expect(startButton).toBeNull();
});
});
describe('endpoint list take action with RBAC controls', () => {
let renderResult: ReturnType<AppContextTestRender['render']>;

View file

@ -5,20 +5,20 @@
* 2.0.
*/
import React, { useMemo, useCallback, memo, useEffect, useState } from 'react';
import React, { type CSSProperties, useCallback, useMemo } from 'react';
import styled from 'styled-components';
import type { EuiBasicTableColumn, EuiSelectableProps } from '@elastic/eui';
import {
EuiHorizontalRule,
EuiBasicTable,
EuiText,
EuiLink,
EuiHealth,
EuiToolTip,
EuiSuperDatePicker,
EuiSpacer,
type EuiBasicTableColumn,
EuiFlexGroup,
EuiFlexItem,
EuiHealth,
EuiHorizontalRule,
type EuiSelectableProps,
EuiSpacer,
EuiSuperDatePicker,
EuiText,
EuiToolTip,
} from '@elastic/eui';
import { useHistory, useLocation } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
@ -26,85 +26,285 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { createStructuredSelector } from 'reselect';
import { useDispatch } from 'react-redux';
import type {
CreatePackagePolicyRouteState,
AgentPolicyDetailsDeployAgentAction,
CreatePackagePolicyRouteState,
} from '@kbn/fleet-plugin/public';
import { TransformFailedCallout } from './components/transform_failed_callout';
import type { EndpointIndexUIQueryParams } from '../types';
import { EndpointListNavLink } from './components/endpoint_list_nav_link';
import { EndpointAgentStatus } from '../../../../common/components/endpoint/endpoint_agent_status';
import { EndpointDetailsFlyout } from './details';
import * as selectors from '../store/selectors';
import { getEndpointPendingActionsCallback } from '../store/selectors';
import { useEndpointSelector } from './hooks';
import { isPolicyOutOfDate } from '../utils';
import { POLICY_STATUS_TO_HEALTH_COLOR, POLICY_STATUS_TO_TEXT } from './host_constants';
import { useNavigateByRouterEventHandler } from '../../../../common/hooks/endpoint/use_navigate_by_router_event_handler';
import type { CreateStructuredSelector } from '../../../../common/store';
import type {
Immutable,
HostInfo,
Immutable,
PolicyDetailsRouteState,
} from '../../../../../common/endpoint/types';
import { DEFAULT_POLL_INTERVAL, MANAGEMENT_PAGE_SIZE_OPTIONS } from '../../../common/constants';
import { PolicyEmptyState, HostsEmptyState } from '../../../components/management_empty_state';
import { HostsEmptyState, PolicyEmptyState } from '../../../components/management_empty_state';
import { FormattedDate } from '../../../../common/components/formatted_date';
import { useNavigateToAppEventHandler } from '../../../../common/hooks/endpoint/use_navigate_to_app_event_handler';
import { EndpointPolicyLink } from '../../../components/endpoint_policy_link';
import { SecurityPageName } from '../../../../app/types';
import {
getEndpointListPath,
getEndpointDetailsPath,
getPoliciesPath,
} from '../../../common/routing';
import { getEndpointDetailsPath, getEndpointListPath } from '../../../common/routing';
import { useFormatUrl } from '../../../../common/components/link_to';
import { useAppUrl } from '../../../../common/lib/kibana/hooks';
import type { EndpointAction } from '../store/action';
import { OutOfDate } from './components/out_of_date';
import { AdminSearchBar } from './components/search_bar';
import { AdministrationListPage } from '../../../components/administration_list_page';
import { LinkToApp } from '../../../../common/components/endpoint/link_to_app';
import { TableRowActions } from './components/table_row_actions';
import { CallOut } from '../../../../common/components/callouts';
import { metadataTransformPrefix } from '../../../../../common/endpoint/constants';
import { WARNING_TRANSFORM_STATES, APP_UI_ID } from '../../../../../common/constants';
import type { BackToExternalAppButtonProps } from '../../../components/back_to_external_app_button/back_to_external_app_button';
import { BackToExternalAppButton } from '../../../components/back_to_external_app_button/back_to_external_app_button';
import { APP_UI_ID } from '../../../../../common/constants';
import { ManagementEmptyStateWrapper } from '../../../components/management_empty_state_wrapper';
import { useUserPrivileges } from '../../../../common/components/user_privileges';
import { useKibana } from '../../../../common/lib/kibana';
import { getEndpointPendingActionsCallback } from '../store/selectors';
import { BackToPolicyListButton } from './components/back_to_policy_list_button';
const MAX_PAGINATED_ITEM = 9999;
const TRANSFORM_URL = '/data/transform';
const StyledDatePicker = styled.div`
.euiFormControlLayout--group {
background-color: rgba(0, 119, 204, 0.2);
}
`;
const EndpointListNavLink = memo<{
name: string;
href: string;
route: string;
dataTestSubj: string;
}>(({ name, href, route, dataTestSubj }) => {
const clickHandler = useNavigateByRouterEventHandler(route);
return (
// eslint-disable-next-line @elastic/eui/href-or-on-click
<EuiLink
data-test-subj={dataTestSubj}
className="eui-displayInline eui-textTruncate"
href={href}
onClick={clickHandler}
>
{name}
</EuiLink>
);
});
EndpointListNavLink.displayName = 'EndpointListNavLink';
interface GetEndpointListColumnsProps {
canReadPolicyManagement: boolean;
backToEndpointList: PolicyDetailsRouteState['backLink'];
getHostPendingActions: ReturnType<typeof getEndpointPendingActionsCallback>;
queryParams: Immutable<EndpointIndexUIQueryParams>;
search: string;
getAppUrl: ReturnType<typeof useAppUrl>['getAppUrl'];
}
const getEndpointListColumns = ({
canReadPolicyManagement,
backToEndpointList,
getHostPendingActions,
queryParams,
search,
getAppUrl,
}: GetEndpointListColumnsProps): Array<EuiBasicTableColumn<Immutable<HostInfo>>> => {
const lastActiveColumnName = i18n.translate('xpack.securitySolution.endpoint.list.lastActive', {
defaultMessage: 'Last active',
});
const padLeft: CSSProperties = { paddingLeft: '6px' };
return [
{
field: 'metadata',
width: '15%',
name: i18n.translate('xpack.securitySolution.endpoint.list.hostname', {
defaultMessage: 'Endpoint',
}),
render: ({ host: { hostname }, agent: { id } }: HostInfo['metadata']) => {
const toRoutePath = getEndpointDetailsPath(
{
...queryParams,
name: 'endpointDetails',
selected_endpoint: id,
},
search
);
const toRouteUrl = getAppUrl({ path: toRoutePath });
return (
<EuiToolTip content={hostname} anchorClassName="eui-textTruncate">
<EndpointListNavLink
name={hostname}
href={toRouteUrl}
route={toRoutePath}
dataTestSubj="hostnameCellLink"
/>
</EuiToolTip>
);
},
},
{
field: 'host_status',
width: '14%',
name: i18n.translate('xpack.securitySolution.endpoint.list.hostStatus', {
defaultMessage: 'Agent status',
}),
render: (hostStatus: HostInfo['host_status'], endpointInfo) => {
return (
<EndpointAgentStatus
endpointHostInfo={endpointInfo}
pendingActions={getHostPendingActions(endpointInfo.metadata.agent.id)}
data-test-subj="rowHostStatus"
/>
);
},
},
{
field: 'metadata.Endpoint.policy.applied',
width: '15%',
name: i18n.translate('xpack.securitySolution.endpoint.list.policy', {
defaultMessage: 'Policy',
}),
truncateText: true,
render: (policy: HostInfo['metadata']['Endpoint']['policy']['applied'], item: HostInfo) => {
return (
<>
<EuiToolTip content={policy.name} anchorClassName="eui-textTruncate">
{canReadPolicyManagement ? (
<EndpointPolicyLink
policyId={policy.id}
className="eui-textTruncate"
data-test-subj="policyNameCellLink"
backLink={backToEndpointList}
>
{policy.name}
</EndpointPolicyLink>
) : (
<>{policy.name}</>
)}
</EuiToolTip>
{policy.endpoint_policy_version && (
<EuiText
color="subdued"
size="xs"
style={{ whiteSpace: 'nowrap', ...padLeft }}
className="eui-textTruncate"
data-test-subj="policyListRevNo"
>
<FormattedMessage
id="xpack.securitySolution.endpoint.list.policy.revisionNumber"
defaultMessage="rev. {revNumber}"
values={{ revNumber: policy.endpoint_policy_version }}
/>
</EuiText>
)}
{isPolicyOutOfDate(policy, item.policy_info) && (
<OutOfDate style={padLeft} data-test-subj="rowPolicyOutOfDate" />
)}
</>
);
},
},
{
field: 'metadata.Endpoint.policy.applied',
width: '9%',
name: i18n.translate('xpack.securitySolution.endpoint.list.policyStatus', {
defaultMessage: 'Policy status',
}),
render: (policy: HostInfo['metadata']['Endpoint']['policy']['applied'], item: HostInfo) => {
const toRoutePath = getEndpointDetailsPath({
name: 'endpointPolicyResponse',
...queryParams,
selected_endpoint: item.metadata.agent.id,
});
const toRouteUrl = getAppUrl({ path: toRoutePath });
return (
<EuiToolTip
content={POLICY_STATUS_TO_TEXT[policy.status]}
anchorClassName="eui-textTruncate"
>
<EuiHealth
color={POLICY_STATUS_TO_HEALTH_COLOR[policy.status]}
className="eui-textTruncate eui-fullWidth"
data-test-subj="rowPolicyStatus"
>
<EndpointListNavLink
name={POLICY_STATUS_TO_TEXT[policy.status]}
href={toRouteUrl}
route={toRoutePath}
dataTestSubj="policyStatusCellLink"
/>
</EuiHealth>
</EuiToolTip>
);
},
},
{
field: 'metadata.host.os.name',
width: '9%',
name: i18n.translate('xpack.securitySolution.endpoint.list.os', {
defaultMessage: 'OS',
}),
render: (os: string) => {
return (
<EuiToolTip content={os} anchorClassName="eui-textTruncate">
<EuiText size="s" className="eui-textTruncate eui-fullWidth">
<p className="eui-displayInline eui-TextTruncate">{os}</p>
</EuiText>
</EuiToolTip>
);
},
},
{
field: 'metadata.host.ip',
width: '12%',
name: i18n.translate('xpack.securitySolution.endpoint.list.ip', {
defaultMessage: 'IP address',
}),
render: (ip: string[]) => {
return (
<EuiToolTip content={ip.toString().replace(',', ', ')} anchorClassName="eui-textTruncate">
<EuiText size="s" className="eui-textTruncate eui-fullWidth">
<p className="eui-displayInline eui-textTruncate">
{ip.toString().replace(',', ', ')}
</p>
</EuiText>
</EuiToolTip>
);
},
},
{
field: 'metadata.agent.version',
width: '9%',
name: i18n.translate('xpack.securitySolution.endpoint.list.endpointVersion', {
defaultMessage: 'Version',
}),
render: (version: string) => {
return (
<EuiToolTip content={version} anchorClassName="eui-textTruncate">
<EuiText size="s" className="eui-textTruncate eui-fullWidth">
<p className="eui-displayInline eui-TextTruncate">{version}</p>
</EuiText>
</EuiToolTip>
);
},
},
{
field: 'metadata.@timestamp',
name: lastActiveColumnName,
width: '9%',
render(dateValue: HostInfo['metadata']['@timestamp']) {
return (
<FormattedDate
fieldName={lastActiveColumnName}
value={dateValue}
className="eui-textTruncate"
/>
);
},
},
{
field: '',
width: '8%',
name: i18n.translate('xpack.securitySolution.endpoint.list.actions', {
defaultMessage: 'Actions',
}),
actions: [
{
render: (item: HostInfo) => {
return <TableRowActions endpointMetadata={item.metadata} />;
},
},
],
},
];
};
// FIXME: this needs refactoring - we are pulling in all selectors from endpoint, which includes many more than what the list uses
const selector = (createStructuredSelector as CreateStructuredSelector)(selectors);
export const EndpointList = () => {
const history = useHistory();
const { services } = useKibana();
const {
listData,
pageIndex,
@ -133,65 +333,30 @@ export const EndpointList = () => {
} = useUserPrivileges().endpointPrivileges;
const { search } = useFormatUrl(SecurityPageName.administration);
const { search: searchParams } = useLocation();
const { state: routeState = {} } = useLocation<PolicyDetailsRouteState>();
const { getAppUrl } = useAppUrl();
const dispatch = useDispatch<(a: EndpointAction) => void>();
// cap ability to page at 10k records. (max_result_window)
const maxPageCount = totalItemCount > MAX_PAGINATED_ITEM ? MAX_PAGINATED_ITEM : totalItemCount;
const [showTransformFailedCallout, setShowTransformFailedCallout] = useState(false);
const [shouldCheckTransforms, setShouldCheckTransforms] = useState(true);
const { state: routeState = {} } = useLocation<PolicyDetailsRouteState>();
const hasPolicyData = useMemo(() => policyItems && policyItems.length > 0, [policyItems]);
const hasListData = useMemo(() => listData && listData.length > 0, [listData]);
const backLinkOptions = useMemo<BackToExternalAppButtonProps>(() => {
if (routeState?.backLink) {
return {
onBackButtonNavigateTo: routeState.backLink.navigateTo,
backButtonLabel: routeState.backLink.label,
backButtonUrl: routeState.backLink.href,
};
}
const refreshStyle = useMemo(() => {
return { display: endpointsExist ? 'flex' : 'none', maxWidth: 200 };
}, [endpointsExist]);
// default back button is to the policy list
const policyListPath = getPoliciesPath();
const refreshIsPaused = useMemo(() => {
return !endpointsExist ? false : hasSelectedEndpoint ? true : !isAutoRefreshEnabled;
}, [endpointsExist, hasSelectedEndpoint, isAutoRefreshEnabled]);
return {
backButtonLabel: i18n.translate('xpack.securitySolution.endpoint.list.backToPolicyButton', {
defaultMessage: 'Back to policy list',
}),
backButtonUrl: getAppUrl({ path: policyListPath }),
onBackButtonNavigateTo: [
APP_UI_ID,
{
path: policyListPath,
},
],
};
}, [getAppUrl, routeState?.backLink]);
const refreshInterval = useMemo(() => {
return !endpointsExist ? DEFAULT_POLL_INTERVAL : autoRefreshInterval;
}, [endpointsExist, autoRefreshInterval]);
const backToPolicyList = (
<BackToExternalAppButton {...backLinkOptions} data-test-subj="endpointListBackLink" />
);
useEffect(() => {
// if no endpoint policy, skip transform check
if (!shouldCheckTransforms || !policyItems || !policyItems.length) {
return;
}
dispatch({ type: 'loadMetadataTransformStats' });
setShouldCheckTransforms(false);
}, [policyItems, shouldCheckTransforms, dispatch]);
useEffect(() => {
const hasFailure = metadataTransformStats.some((transform) =>
WARNING_TRANSFORM_STATES.has(transform?.state)
);
setShowTransformFailedCallout(hasFailure);
}, [metadataTransformStats]);
const closeTransformFailedCallout = useCallback(() => {
setShowTransformFailedCallout(false);
}, []);
const shouldShowKQLBar = useMemo(() => {
return endpointsExist && !patternsError;
}, [endpointsExist, patternsError]);
const paginationSetup = useMemo(() => {
return {
@ -279,9 +444,8 @@ export const EndpointList = () => {
[dispatch]
);
const NOOP = useCallback(() => {}, []);
const PAD_LEFT: React.CSSProperties = useMemo(() => ({ paddingLeft: '6px' }), []);
// Used for an auto-refresh super date picker version without any date/time selection
const onTimeChange = useCallback(() => {}, []);
const handleDeployEndpointsClick =
useNavigateToAppEventHandler<AgentPolicyDetailsDeployAgentAction>('fleet', {
@ -291,16 +455,6 @@ export const EndpointList = () => {
},
});
const selectionOptions = useMemo<EuiSelectableProps['options']>(() => {
return policyItems.map((item) => {
return {
key: item.policy_id,
label: item.name,
checked: selectedPolicyId === item.policy_id ? 'on' : undefined,
};
});
}, [policyItems, selectedPolicyId]);
const handleSelectableOnChange = useCallback<(o: EuiSelectableProps['options']) => void>(
(changedOptions) => {
return changedOptions.some((option) => {
@ -326,227 +480,25 @@ export const EndpointList = () => {
};
}, []);
const columns: Array<EuiBasicTableColumn<Immutable<HostInfo>>> = useMemo(() => {
const lastActiveColumnName = i18n.translate('xpack.securitySolution.endpoint.list.lastActive', {
defaultMessage: 'Last active',
});
return [
{
field: 'metadata',
width: '15%',
name: i18n.translate('xpack.securitySolution.endpoint.list.hostname', {
defaultMessage: 'Endpoint',
}),
render: ({ host: { hostname }, agent: { id } }: HostInfo['metadata']) => {
const toRoutePath = getEndpointDetailsPath(
{
...queryParams,
name: 'endpointDetails',
selected_endpoint: id,
},
search
);
const toRouteUrl = getAppUrl({ path: toRoutePath });
return (
<EuiToolTip content={hostname} anchorClassName="eui-textTruncate">
<EndpointListNavLink
name={hostname}
href={toRouteUrl}
route={toRoutePath}
dataTestSubj="hostnameCellLink"
/>
</EuiToolTip>
);
},
},
{
field: 'host_status',
width: '14%',
name: i18n.translate('xpack.securitySolution.endpoint.list.hostStatus', {
defaultMessage: 'Agent status',
}),
render: (hostStatus: HostInfo['host_status'], endpointInfo) => {
return (
<EndpointAgentStatus
endpointHostInfo={endpointInfo}
pendingActions={getHostPendingActions(endpointInfo.metadata.agent.id)}
data-test-subj="rowHostStatus"
/>
);
},
},
{
field: 'metadata.Endpoint.policy.applied',
width: '15%',
name: i18n.translate('xpack.securitySolution.endpoint.list.policy', {
defaultMessage: 'Policy',
}),
truncateText: true,
render: (policy: HostInfo['metadata']['Endpoint']['policy']['applied'], item: HostInfo) => {
return (
<>
<EuiToolTip content={policy.name} anchorClassName="eui-textTruncate">
{canReadPolicyManagement ? (
<EndpointPolicyLink
policyId={policy.id}
className="eui-textTruncate"
data-test-subj="policyNameCellLink"
backLink={backToEndpointList}
>
{policy.name}
</EndpointPolicyLink>
) : (
<>{policy.name}</>
)}
</EuiToolTip>
{policy.endpoint_policy_version && (
<EuiText
color="subdued"
size="xs"
style={{ whiteSpace: 'nowrap', ...PAD_LEFT }}
className="eui-textTruncate"
data-test-subj="policyListRevNo"
>
<FormattedMessage
id="xpack.securitySolution.endpoint.list.policy.revisionNumber"
defaultMessage="rev. {revNumber}"
values={{ revNumber: policy.endpoint_policy_version }}
/>
</EuiText>
)}
{isPolicyOutOfDate(policy, item.policy_info) && (
<OutOfDate style={PAD_LEFT} data-test-subj="rowPolicyOutOfDate" />
)}
</>
);
},
},
{
field: 'metadata.Endpoint.policy.applied',
width: '9%',
name: i18n.translate('xpack.securitySolution.endpoint.list.policyStatus', {
defaultMessage: 'Policy status',
}),
render: (policy: HostInfo['metadata']['Endpoint']['policy']['applied'], item: HostInfo) => {
const toRoutePath = getEndpointDetailsPath({
name: 'endpointPolicyResponse',
...queryParams,
selected_endpoint: item.metadata.agent.id,
});
const toRouteUrl = getAppUrl({ path: toRoutePath });
return (
<EuiToolTip
content={POLICY_STATUS_TO_TEXT[policy.status]}
anchorClassName="eui-textTruncate"
>
<EuiHealth
color={POLICY_STATUS_TO_HEALTH_COLOR[policy.status]}
className="eui-textTruncate eui-fullWidth"
data-test-subj="rowPolicyStatus"
>
<EndpointListNavLink
name={POLICY_STATUS_TO_TEXT[policy.status]}
href={toRouteUrl}
route={toRoutePath}
dataTestSubj="policyStatusCellLink"
/>
</EuiHealth>
</EuiToolTip>
);
},
},
{
field: 'metadata.host.os.name',
width: '9%',
name: i18n.translate('xpack.securitySolution.endpoint.list.os', {
defaultMessage: 'OS',
}),
render: (os: string) => {
return (
<EuiToolTip content={os} anchorClassName="eui-textTruncate">
<EuiText size="s" className="eui-textTruncate eui-fullWidth">
<p className="eui-displayInline eui-TextTruncate">{os}</p>
</EuiText>
</EuiToolTip>
);
},
},
{
field: 'metadata.host.ip',
width: '12%',
name: i18n.translate('xpack.securitySolution.endpoint.list.ip', {
defaultMessage: 'IP address',
}),
render: (ip: string[]) => {
return (
<EuiToolTip
content={ip.toString().replace(',', ', ')}
anchorClassName="eui-textTruncate"
>
<EuiText size="s" className="eui-textTruncate eui-fullWidth">
<p className="eui-displayInline eui-textTruncate">
{ip.toString().replace(',', ', ')}
</p>
</EuiText>
</EuiToolTip>
);
},
},
{
field: 'metadata.agent.version',
width: '9%',
name: i18n.translate('xpack.securitySolution.endpoint.list.endpointVersion', {
defaultMessage: 'Version',
}),
render: (version: string) => {
return (
<EuiToolTip content={version} anchorClassName="eui-textTruncate">
<EuiText size="s" className="eui-textTruncate eui-fullWidth">
<p className="eui-displayInline eui-TextTruncate">{version}</p>
</EuiText>
</EuiToolTip>
);
},
},
{
field: 'metadata.@timestamp',
name: lastActiveColumnName,
width: '9%',
render(dateValue: HostInfo['metadata']['@timestamp']) {
return (
<FormattedDate
fieldName={lastActiveColumnName}
value={dateValue}
className="eui-textTruncate"
/>
);
},
},
{
field: '',
width: '8%',
name: i18n.translate('xpack.securitySolution.endpoint.list.actions', {
defaultMessage: 'Actions',
}),
actions: [
{
render: (item: HostInfo) => {
return <TableRowActions endpointMetadata={item.metadata} />;
},
},
],
},
];
}, [
queryParams,
search,
getAppUrl,
getHostPendingActions,
canReadPolicyManagement,
backToEndpointList,
PAD_LEFT,
]);
const columns = useMemo(
() =>
getEndpointListColumns({
canReadPolicyManagement,
backToEndpointList,
getAppUrl,
getHostPendingActions,
queryParams,
search,
}),
[
backToEndpointList,
canReadPolicyManagement,
getAppUrl,
getHostPendingActions,
queryParams,
search,
]
);
const renderTableOrEmptyState = useMemo(() => {
if (endpointsExist) {
@ -568,12 +520,19 @@ export const EndpointList = () => {
<PolicyEmptyState loading={endpointPrivilegesLoading} />
</ManagementEmptyStateWrapper>
);
} else if (!policyItemsLoading && policyItems && policyItems.length > 0) {
} else if (!policyItemsLoading && hasPolicyData) {
const selectionOptions: EuiSelectableProps['options'] = policyItems.map((item) => {
return {
key: item.policy_id,
label: item.name,
checked: selectedPolicyId === item.policy_id ? 'on' : undefined,
};
});
return (
<HostsEmptyState
loading={loading}
onActionClick={handleDeployEndpointsClick}
actionDisabled={selectedPolicyId === undefined}
actionDisabled={!selectedPolicyId}
handleSelectableOnChange={handleSelectableOnChange}
selectionOptions={selectionOptions}
/>
@ -586,118 +545,26 @@ export const EndpointList = () => {
);
}
}, [
endpointsExist,
policyItemsLoading,
policyItems,
listData,
columns,
listError?.message,
paginationSetup,
onTableChange,
loading,
setTableRowProps,
handleDeployEndpointsClick,
selectedPolicyId,
handleSelectableOnChange,
selectionOptions,
handleCreatePolicyClick,
canAccessFleet,
canReadEndpointList,
columns,
endpointsExist,
endpointPrivilegesLoading,
handleCreatePolicyClick,
handleDeployEndpointsClick,
handleSelectableOnChange,
hasPolicyData,
listData,
listError?.message,
loading,
onTableChange,
paginationSetup,
policyItemsLoading,
policyItems,
selectedPolicyId,
setTableRowProps,
]);
const hasListData = listData && listData.length > 0;
const refreshStyle = useMemo(() => {
return { display: endpointsExist ? 'flex' : 'none', maxWidth: 200 };
}, [endpointsExist]);
const refreshIsPaused = useMemo(() => {
return !endpointsExist ? false : hasSelectedEndpoint ? true : !isAutoRefreshEnabled;
}, [endpointsExist, hasSelectedEndpoint, isAutoRefreshEnabled]);
const refreshInterval = useMemo(() => {
return !endpointsExist ? DEFAULT_POLL_INTERVAL : autoRefreshInterval;
}, [endpointsExist, autoRefreshInterval]);
const shouldShowKQLBar = useMemo(() => {
return endpointsExist && !patternsError;
}, [endpointsExist, patternsError]);
const transformFailedCalloutDescription = useMemo(() => {
const failingTransformIds = metadataTransformStats
.filter((transformStat) => WARNING_TRANSFORM_STATES.has(transformStat.state))
.map((transformStat) => transformStat.id)
.join(', ');
return (
<>
<FormattedMessage
id="xpack.securitySolution.endpoint.list.transformFailed.message"
defaultMessage="A required transform, {transformId}, is currently failing. Most of the time this can be fixed by {transformsPage}. For additional help, please visit the {docsPage}"
values={{
transformId: failingTransformIds || metadataTransformPrefix,
transformsPage: (
<LinkToApp
data-test-subj="failed-transform-restart-link"
appId="management"
appPath={TRANSFORM_URL}
>
<FormattedMessage
id="xpack.securitySolution.endpoint.list.transformFailed.restartLink"
defaultMessage="restarting the transform"
/>
</LinkToApp>
),
docsPage: (
<EuiLink
data-test-subj="failed-transform-docs-link"
href={services.docLinks.links.endpoints.troubleshooting}
target="_blank"
>
<FormattedMessage
id="xpack.securitySolution.endpoint.list.transformFailed.docsLink"
defaultMessage="troubleshooting documentation"
/>
</EuiLink>
),
}}
/>
<EuiSpacer size="s" />
</>
);
}, [metadataTransformStats, services.docLinks.links.endpoints.troubleshooting]);
const transformFailedCallout = useMemo(() => {
if (!showTransformFailedCallout) {
return;
}
return (
<>
<CallOut
message={{
id: 'endpoints-list-transform-failed',
type: 'warning',
title: i18n.translate('xpack.securitySolution.endpoint.list.transformFailed.title', {
defaultMessage: 'Required transform failed',
}),
description: transformFailedCalloutDescription,
}}
dismissButtonText={i18n.translate(
'xpack.securitySolution.endpoint.list.transformFailed.dismiss',
{
defaultMessage: 'Dismiss',
}
)}
onDismiss={closeTransformFailedCallout}
showDismissButton={true}
/>
<EuiSpacer size="m" />
</>
);
}, [showTransformFailedCallout, closeTransformFailedCallout, transformFailedCalloutDescription]);
return (
<AdministrationListPage
data-test-subj="endpointPage"
@ -714,11 +581,14 @@ export const EndpointList = () => {
defaultMessage="Hosts running Elastic Defend"
/>
}
headerBackComponent={routeState.backLink && backToPolicyList}
headerBackComponent={<BackToPolicyListButton backLink={routeState.backLink} />}
>
{hasSelectedEndpoint && <EndpointDetailsFlyout />}
<>
{transformFailedCallout}
<TransformFailedCallout
metadataTransformStats={metadataTransformStats}
hasNoPolicyData={!hasPolicyData}
/>
<EuiFlexGroup gutterSize="s" alignItems="center">
{shouldShowKQLBar && (
<EuiFlexItem>
@ -729,7 +599,7 @@ export const EndpointList = () => {
<StyledDatePicker>
<EuiSuperDatePicker
className="endpointListDatePicker"
onTimeChange={NOOP}
onTimeChange={onTimeChange}
isDisabled={hasSelectedEndpoint}
onRefresh={onRefresh}
isPaused={refreshIsPaused}