[Security Solution][Endpoint] Paginate actions log with infinite scroll (#102261)

* Show loading below the list when loading

fixes elastic/security-team/issues/1245

* use intersection observer to load data when callout is visible

fixes elastic/security-team/issues/1245

* remove unused `total` from API response

refs 4f7d18bee7

* toggle ability to paging based on API response and target intersection

fixes elastic/security-team/issues/1245

* use a invisible target

* display a message when end of log

fixes elastic/security-team/issues/1245

* remove search bar

fixes elastic/security-team/issues/1245

* refresh data

fixes elastic/security-team/issues/1245

* rename

refs 85e5add14e

* add refresh button to empty state

* add translations for copy

* remove refresh button

* load activity log for endpoint on activity log tab selection

fixes elastic/security-team/issues/1312

* reset paging correctly on activity log tab selection

* fix variable mixup

refs elastic/kibana/pull/101032/commits/c4e933a9c5954ce249942ca66bab380c1dfa79e2#diff-41a74ad41665921620230a0729728f3bf6e27a6f9dc302fb37b0d2061637c212R81

* fix react warning

refs 697a3c3bac

* clean up

review changes

* use the complicated flyout version instead of styled version

refs https://elastic.github.io/eui/#/layout/flyout#more-complicated-flyout
refs https://github.com/elastic/kibana/pull/99795/files#r635810660
refs c26a7d47b4

* Page only when scrolled (so that info message is shown after paging once)

fixes https://github.com/elastic/security-team/issues/1245#issuecomment-863440335

* add tests

fixes elastic/security-team/issues/1312
fixes elastic/security-team/issues/1245

* increase the parent container's height to ensure that the scroll target is well hidden below the footer

refs 48e3291681

* Update x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts

Co-authored-by: Paul Tavares <56442535+paul-tavares@users.noreply.github.com>

* Update x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx

Co-authored-by: Paul Tavares <56442535+paul-tavares@users.noreply.github.com>

* address review changes

* cleanup callback and effect

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Paul Tavares <56442535+paul-tavares@users.noreply.github.com>
This commit is contained in:
Ashokaditya 2021-06-23 09:19:13 +02:00 committed by GitHub
parent 3e952faf88
commit b12ddfabf0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 524 additions and 164 deletions

View file

@ -58,7 +58,6 @@ export interface ActivityLogActionResponse {
}
export type ActivityLogEntry = ActivityLogAction | ActivityLogActionResponse;
export interface ActivityLog {
total: number;
page: number;
pageSize: number;
data: ActivityLogEntry[];

View file

@ -16,7 +16,7 @@ import {
import { ServerApiError } from '../../../../common/types';
import { GetPolicyListResponse } from '../../policy/types';
import { GetPackagesResponse } from '../../../../../../fleet/common';
import { EndpointState } from '../types';
import { EndpointIndexUIQueryParams, EndpointState } from '../types';
import { IIndexPattern } from '../../../../../../../../src/plugins/data/public';
export interface ServerReturnedEndpointList {
@ -163,12 +163,29 @@ export type EndpointPendingActionsStateChanged = Action<'endpointPendingActionsS
payload: EndpointState['endpointPendingActions'];
};
export interface EndpointDetailsActivityLogUpdatePaging {
type: 'endpointDetailsActivityLogUpdatePaging';
payload: {
// disable paging when no more data after paging
disabled: boolean;
page: number;
pageSize: number;
};
}
export interface EndpointDetailsFlyoutTabChanged {
type: 'endpointDetailsFlyoutTabChanged';
payload: { flyoutView: EndpointIndexUIQueryParams['show'] };
}
export type EndpointAction =
| ServerReturnedEndpointList
| ServerFailedToReturnEndpointList
| ServerReturnedEndpointDetails
| ServerFailedToReturnEndpointDetails
| AppRequestedEndpointActivityLog
| EndpointDetailsActivityLogUpdatePaging
| EndpointDetailsFlyoutTabChanged
| EndpointDetailsActivityLogChanged
| ServerReturnedEndpointPolicyResponse
| ServerFailedToReturnEndpointPolicyResponse

View file

@ -19,9 +19,13 @@ export const initialEndpointPageState = (): Immutable<EndpointState> => {
loading: false,
error: undefined,
endpointDetails: {
flyoutView: undefined,
activityLog: {
page: 1,
pageSize: 50,
paging: {
disabled: false,
page: 1,
pageSize: 50,
},
logData: createUninitialisedResourceState(),
},
hostDetails: {

View file

@ -42,9 +42,13 @@ describe('EndpointList store concerns', () => {
loading: false,
error: undefined,
endpointDetails: {
flyoutView: undefined,
activityLog: {
page: 1,
pageSize: 50,
paging: {
disabled: false,
page: 1,
pageSize: 50,
},
logData: { type: 'UninitialisedResourceState' },
},
hostDetails: {

View file

@ -44,6 +44,7 @@ import {
} from '../../../../common/lib/endpoint_isolation/mocks';
import { FleetActionGenerator } from '../../../../../common/endpoint/data_generators/fleet_action_generator';
import { endpointPageHttpMock } from '../mocks';
import { EndpointDetailsTabsTypes } from '../view/details/components/endpoint_details_tabs';
jest.mock('../../policy/store/services/ingest', () => ({
sendGetAgentConfigList: () => Promise.resolve({ items: [] }),
@ -226,8 +227,16 @@ describe('endpoint list middleware', () => {
const dispatchUserChangedUrl = () => {
dispatchUserChangedUrlToEndpointList({ search: `?${search.split('?').pop()}` });
};
const dispatchFlyoutViewChange = () => {
dispatch({
type: 'endpointDetailsFlyoutTabChanged',
payload: {
flyoutView: EndpointDetailsTabsTypes.activityLog,
},
});
};
const fleetActionGenerator = new FleetActionGenerator(Math.random().toString());
const fleetActionGenerator = new FleetActionGenerator('seed');
const actionData = fleetActionGenerator.generate({
agents: [endpointList.hosts[0].metadata.agent.id],
});
@ -265,6 +274,7 @@ describe('endpoint list middleware', () => {
it('should set ActivityLog state to loading', async () => {
dispatchUserChangedUrl();
dispatchFlyoutViewChange();
const loadingDispatched = waitForAction('endpointDetailsActivityLogChanged', {
validate(action) {

View file

@ -35,6 +35,7 @@ import {
getActivityLogDataPaging,
getLastLoadedActivityLogData,
detailsData,
getEndpointDetailsFlyoutView,
} from './selectors';
import { AgentIdsPendingActions, EndpointState, PolicyIds } from '../types';
import {
@ -48,6 +49,7 @@ import {
ENDPOINT_ACTION_LOG_ROUTE,
HOST_METADATA_GET_ROUTE,
HOST_METADATA_LIST_ROUTE,
BASE_POLICY_RESPONSE_ROUTE,
metadataCurrentIndexPattern,
} from '../../../../../common/endpoint/constants';
import { IIndexPattern, Query } from '../../../../../../../../src/plugins/data/public';
@ -61,6 +63,7 @@ import { AppAction } from '../../../../common/store/actions';
import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables';
import { ServerReturnedEndpointPackageInfo } from './action';
import { fetchPendingActionsByAgentId } from '../../../../common/lib/endpoint_pending_actions';
import { EndpointDetailsTabsTypes } from '../view/details/components/endpoint_details_tabs';
type EndpointPageStore = ImmutableMiddlewareAPI<EndpointState, AppAction>;
@ -339,6 +342,28 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory<EndpointState
loadEndpointsPendingActions(store);
// call the policy response api
try {
const policyResponse = await coreStart.http.get(BASE_POLICY_RESPONSE_ROUTE, {
query: { agentId: selectedEndpoint },
});
dispatch({
type: 'serverReturnedEndpointPolicyResponse',
payload: policyResponse,
});
} catch (error) {
dispatch({
type: 'serverFailedToReturnEndpointPolicyResponse',
payload: error,
});
}
}
if (
action.type === 'userChangedUrl' &&
hasSelectedEndpoint(getState()) === true &&
getEndpointDetailsFlyoutView(getState()) === EndpointDetailsTabsTypes.activityLog
) {
// call the activity log api
dispatch({
type: 'endpointDetailsActivityLogChanged',
@ -365,22 +390,6 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory<EndpointState
payload: createFailedResourceState<ActivityLog>(error.body ?? error),
});
}
// call the policy response api
try {
const policyResponse = await coreStart.http.get(`/api/endpoint/policy_response`, {
query: { agentId: selectedEndpoint },
});
dispatch({
type: 'serverReturnedEndpointPolicyResponse',
payload: policyResponse,
});
} catch (error) {
dispatch({
type: 'serverFailedToReturnEndpointPolicyResponse',
payload: error,
});
}
}
// page activity log API
@ -408,17 +417,24 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory<EndpointState
] as ActivityLog['data'];
const updatedLogData = {
total: activityLog.total,
page: activityLog.page,
pageSize: activityLog.pageSize,
data: updatedLogDataItems,
data: activityLog.page === 1 ? activityLog.data : updatedLogDataItems,
};
dispatch({
type: 'endpointDetailsActivityLogChanged',
payload: createLoadedResourceState<ActivityLog>(updatedLogData),
});
// TODO dispatch 'noNewLogData' if !activityLog.length
// resets paging to previous state
if (!activityLog.data.length) {
dispatch({
type: 'endpointDetailsActivityLogUpdatePaging',
payload: {
disabled: true,
page: activityLog.page - 1,
pageSize: activityLog.pageSize,
},
});
}
} else {
dispatch({
type: 'endpointDetailsActivityLogChanged',

View file

@ -29,12 +29,23 @@ const handleEndpointDetailsActivityLogChanged: CaseReducer<EndpointDetailsActivi
state,
action
) => {
const pagingOptions =
action.payload.type === 'LoadedResourceState'
? {
...state.endpointDetails.activityLog,
paging: {
...state.endpointDetails.activityLog.paging,
page: action.payload.data.page,
pageSize: action.payload.data.pageSize,
},
}
: { ...state.endpointDetails.activityLog };
return {
...state!,
endpointDetails: {
...state.endpointDetails!,
activityLog: {
...state.endpointDetails.activityLog,
...pagingOptions,
logData: action.payload,
},
},
@ -138,7 +149,8 @@ export const endpointListReducer: StateReducer = (state = initialEndpointPageSta
},
};
} else if (action.type === 'appRequestedEndpointActivityLog') {
const pageData = {
const paging = {
disabled: state.endpointDetails.activityLog.paging.disabled,
page: action.payload.page,
pageSize: action.payload.pageSize,
};
@ -148,10 +160,32 @@ export const endpointListReducer: StateReducer = (state = initialEndpointPageSta
...state.endpointDetails!,
activityLog: {
...state.endpointDetails.activityLog,
...pageData,
paging,
},
},
};
} else if (action.type === 'endpointDetailsActivityLogUpdatePaging') {
const paging = {
...action.payload,
};
return {
...state,
endpointDetails: {
...state.endpointDetails!,
activityLog: {
...state.endpointDetails.activityLog,
paging,
},
},
};
} else if (action.type === 'endpointDetailsFlyoutTabChanged') {
return {
...state,
endpointDetails: {
...state.endpointDetails!,
flyoutView: action.payload.flyoutView,
},
};
} else if (action.type === 'endpointDetailsActivityLogChanged') {
return handleEndpointDetailsActivityLogChanged(state, action);
} else if (action.type === 'endpointPendingActionsStateChanged') {
@ -255,8 +289,11 @@ export const endpointListReducer: StateReducer = (state = initialEndpointPageSta
const activityLog = {
logData: createUninitialisedResourceState(),
page: 1,
pageSize: 50,
paging: {
disabled: false,
page: 1,
pageSize: 50,
},
};
// Reset `isolationRequestState` if needed

View file

@ -364,13 +364,14 @@ export const getIsolationRequestError: (
}
});
export const getEndpointDetailsFlyoutView = (
state: Immutable<EndpointState>
): EndpointIndexUIQueryParams['show'] => state.endpointDetails.flyoutView;
export const getActivityLogDataPaging = (
state: Immutable<EndpointState>
): Immutable<Omit<EndpointState['endpointDetails']['activityLog'], 'logData'>> => {
return {
page: state.endpointDetails.activityLog.page,
pageSize: state.endpointDetails.activityLog.pageSize,
};
): Immutable<EndpointState['endpointDetails']['activityLog']['paging']> => {
return state.endpointDetails.activityLog.paging;
};
export const getActivityLogData = (

View file

@ -37,9 +37,13 @@ export interface EndpointState {
/** api error from retrieving host list */
error?: ServerApiError;
endpointDetails: {
flyoutView: EndpointIndexUIQueryParams['show'];
activityLog: {
page: number;
pageSize: number;
paging: {
disabled: boolean;
page: number;
pageSize: number;
};
logData: AsyncResourceState<ActivityLog>;
};
hostDetails: {

View file

@ -5,10 +5,15 @@
* 2.0.
*/
import { useDispatch } from 'react-redux';
import React, { memo, useCallback, useMemo, useState } from 'react';
import styled from 'styled-components';
import { EuiTabbedContent, EuiTabbedContentTab } from '@elastic/eui';
import { EuiTab, EuiTabs, EuiFlyoutBody, EuiTabbedContentTab, EuiSpacer } from '@elastic/eui';
import { EndpointIndexUIQueryParams } from '../../../types';
import { EndpointAction } from '../../../store/action';
import { useEndpointSelector } from '../../hooks';
import { getActivityLogDataPaging } from '../../../store/selectors';
import { EndpointDetailsFlyoutHeader } from './flyout_header';
export enum EndpointDetailsTabsTypes {
overview = 'overview',
activityLog = 'activity_log',
@ -24,29 +29,18 @@ interface EndpointDetailsTabs {
content: JSX.Element;
}
const StyledEuiTabbedContent = styled(EuiTabbedContent)`
overflow: hidden;
padding-bottom: ${(props) => props.theme.eui.paddingSizes.xl};
> [role='tabpanel'] {
height: 100%;
padding-right: 12px;
overflow: hidden;
overflow-y: auto;
::-webkit-scrollbar {
-webkit-appearance: none;
width: 4px;
}
::-webkit-scrollbar-thumb {
border-radius: 2px;
background-color: rgba(0, 0, 0, 0.5);
-webkit-box-shadow: 0 0 1px rgba(255, 255, 255, 0.5);
}
}
`;
export const EndpointDetailsFlyoutTabs = memo(
({ show, tabs }: { show: EndpointIndexUIQueryParams['show']; tabs: EndpointDetailsTabs[] }) => {
({
hostname,
show,
tabs,
}: {
hostname?: string;
show: EndpointIndexUIQueryParams['show'];
tabs: EndpointDetailsTabs[];
}) => {
const dispatch = useDispatch<(action: EndpointAction) => void>();
const { pageSize } = useEndpointSelector(getActivityLogDataPaging);
const [selectedTabId, setSelectedTabId] = useState<EndpointDetailsTabsId>(() => {
return show === 'details'
? EndpointDetailsTabsTypes.overview
@ -54,8 +48,33 @@ export const EndpointDetailsFlyoutTabs = memo(
});
const handleTabClick = useCallback(
(tab: EuiTabbedContentTab) => setSelectedTabId(tab.id as EndpointDetailsTabsId),
[setSelectedTabId]
(tab: EuiTabbedContentTab) => {
dispatch({
type: 'endpointDetailsFlyoutTabChanged',
payload: {
flyoutView: tab.id as EndpointIndexUIQueryParams['show'],
},
});
if (tab.id === EndpointDetailsTabsTypes.activityLog) {
const paging = {
page: 1,
pageSize,
};
dispatch({
type: 'appRequestedEndpointActivityLog',
payload: paging,
});
dispatch({
type: 'endpointDetailsActivityLogUpdatePaging',
payload: {
disabled: false,
...paging,
},
});
}
return setSelectedTabId(tab.id as EndpointDetailsTabsId);
},
[dispatch, pageSize, setSelectedTabId]
);
const selectedTab = useMemo(() => tabs.find((tab) => tab.id === selectedTabId), [
@ -63,14 +82,27 @@ export const EndpointDetailsFlyoutTabs = memo(
selectedTabId,
]);
const renderTabs = tabs.map((tab) => (
<EuiTab
onClick={() => handleTabClick(tab)}
isSelected={tab.id === selectedTabId}
key={tab.id}
data-test-subj={tab.id}
>
{tab.name}
</EuiTab>
));
return (
<StyledEuiTabbedContent
data-test-subj="endpointDetailsTabs"
tabs={tabs}
selectedTab={selectedTab}
onTabClick={handleTabClick}
key="endpoint-details-tabs"
/>
<>
<EndpointDetailsFlyoutHeader hostname={hostname} hasBorder>
<EuiSpacer size="s" />
<EuiTabs style={{ marginBottom: '-25px' }}>{renderTabs}</EuiTabs>
</EndpointDetailsFlyoutHeader>
<EuiFlyoutBody data-test-subj="endpointDetailsFlyoutBody">
{selectedTab?.content}
</EuiFlyoutBody>
</>
);
}
);

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo } from 'react';
import { EuiFlyoutHeader, EuiLoadingContent, EuiToolTip, EuiTitle } from '@elastic/eui';
import { useEndpointSelector } from '../../hooks';
import { detailsLoading } from '../../../store/selectors';
export const EndpointDetailsFlyoutHeader = memo(
({
hasBorder = false,
hostname,
children,
}: {
hasBorder?: boolean;
hostname?: string;
children?: React.ReactNode | React.ReactNodeArray;
}) => {
const hostDetailsLoading = useEndpointSelector(detailsLoading);
return (
<EuiFlyoutHeader hasBorder={hasBorder}>
{hostDetailsLoading ? (
<EuiLoadingContent lines={1} />
) : (
<EuiToolTip content={hostname} anchorClassName="eui-textTruncate">
<EuiTitle>
<h2
style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}
data-test-subj="endpointDetailsFlyoutTitle"
>
{hostname}
</h2>
</EuiTitle>
</EuiToolTip>
)}
{children}
</EuiFlyoutHeader>
);
}
);
EndpointDetailsFlyoutHeader.displayName = 'EndpointDetailsFlyoutHeader';

View file

@ -78,7 +78,7 @@ const useLogEntryUIProps = (
if (isSuccessful) {
return i18.ACTIVITY_LOG.LogEntry.response.isolationSuccessful;
} else {
return i18.ACTIVITY_LOG.LogEntry.response.isolationSuccessful;
return i18.ACTIVITY_LOG.LogEntry.response.isolationFailed;
}
} else {
if (isSuccessful) {

View file

@ -5,11 +5,19 @@
* 2.0.
*/
import React, { memo, useCallback } from 'react';
import React, { memo, useCallback, useEffect, useRef } from 'react';
import styled from 'styled-components';
import { EuiButton, EuiEmptyPrompt, EuiLoadingContent, EuiSpacer } from '@elastic/eui';
import {
EuiText,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingContent,
EuiEmptyPrompt,
} from '@elastic/eui';
import { useDispatch } from 'react-redux';
import { LogEntry } from './components/log_entry';
import * as i18 from '../translations';
import { Immutable, ActivityLog } from '../../../../../../common/endpoint/types';
import { AsyncResourceState } from '../../../../state';
import { useEndpointSelector } from '../hooks';
@ -19,54 +27,95 @@ import {
getActivityLogError,
getActivityLogIterableData,
getActivityLogRequestLoaded,
getLastLoadedActivityLogData,
getActivityLogRequestLoading,
} from '../../store/selectors';
const LoadMoreTrigger = styled.div`
height: 6px;
width: 100%;
`;
export const EndpointActivityLog = memo(
({ activityLog }: { activityLog: AsyncResourceState<Immutable<ActivityLog>> }) => {
const activityLogLoading = useEndpointSelector(getActivityLogRequestLoading);
const activityLogLoaded = useEndpointSelector(getActivityLogRequestLoaded);
const activityLastLogData = useEndpointSelector(getLastLoadedActivityLogData);
const activityLogData = useEndpointSelector(getActivityLogIterableData);
const activityLogSize = activityLogData.length;
const activityLogError = useEndpointSelector(getActivityLogError);
const dispatch = useDispatch<(a: EndpointAction) => void>();
const { page, pageSize } = useEndpointSelector(getActivityLogDataPaging);
const dispatch = useDispatch<(action: EndpointAction) => void>();
const { page, pageSize, disabled: isPagingDisabled } = useEndpointSelector(
getActivityLogDataPaging
);
const getActivityLog = useCallback(() => {
dispatch({
type: 'appRequestedEndpointActivityLog',
payload: {
page: page + 1,
pageSize,
},
});
}, [dispatch, page, pageSize]);
const loadMoreTrigger = useRef<HTMLInputElement | null>(null);
const getActivityLog = useCallback(
(entries: IntersectionObserverEntry[]) => {
const isTargetIntersecting = entries.some((entry) => entry.isIntersecting);
if (isTargetIntersecting && activityLogLoaded && !isPagingDisabled) {
dispatch({
type: 'appRequestedEndpointActivityLog',
payload: {
page: page + 1,
pageSize,
},
});
}
},
[activityLogLoaded, dispatch, isPagingDisabled, page, pageSize]
);
useEffect(() => {
const observer = new IntersectionObserver(getActivityLog);
const element = loadMoreTrigger.current;
if (element) {
observer.observe(element);
}
return () => {
observer.disconnect();
};
}, [getActivityLog]);
return (
<>
<EuiSpacer size="l" />
{activityLogLoading || activityLogError ? (
<EuiEmptyPrompt
iconType="editorUnorderedList"
titleSize="s"
title={<h2>{'No logged actions'}</h2>}
body={<p>{'No actions have been logged for this endpoint.'}</p>}
/>
) : (
<>
<EuiSpacer size="l" />
{activityLogLoading ? (
<EuiLoadingContent lines={3} />
) : (
activityLogLoaded &&
activityLogData.map((logEntry) => (
<LogEntry key={`${logEntry.item.id}`} logEntry={logEntry} />
))
)}
<EuiButton size="s" fill onClick={getActivityLog}>
{'show more'}
</EuiButton>
</>
)}
<EuiFlexGroup direction="column" style={{ height: '85vh' }}>
{(activityLogLoaded && !activityLogSize) || activityLogError ? (
<EuiFlexItem>
<EuiEmptyPrompt
iconType="editorUnorderedList"
titleSize="s"
title={<h2>{i18.ACTIVITY_LOG.LogEntry.emptyState.title}</h2>}
body={<p>{i18.ACTIVITY_LOG.LogEntry.emptyState.body}</p>}
data-test-subj="activityLogEmpty"
/>
</EuiFlexItem>
) : (
<>
<EuiFlexItem grow={true}>
{activityLogLoaded &&
activityLogData.map((logEntry) => (
<LogEntry key={`${logEntry.item.id}`} logEntry={logEntry} />
))}
{activityLogLoading &&
activityLastLogData?.data.map((logEntry) => (
<LogEntry key={`${logEntry.item.id}`} logEntry={logEntry} />
))}
</EuiFlexItem>
<EuiFlexItem grow={false}>
{activityLogLoading && <EuiLoadingContent lines={3} />}
{(!activityLogLoading || !isPagingDisabled) && (
<LoadMoreTrigger ref={loadMoreTrigger} />
)}
{isPagingDisabled && !activityLogLoading && (
<EuiText color="subdued" textAlign="center">
<p>{i18.ACTIVITY_LOG.LogEntry.endOfLog}</p>
</EuiText>
)}
</EuiFlexItem>
</>
)}
</EuiFlexGroup>
</>
);
}

View file

@ -20,7 +20,6 @@ export const dummyEndpointActivityLog = (
): AsyncResourceState<Immutable<ActivityLog>> => ({
type: 'LoadedResourceState',
data: {
total: 20,
page: 1,
pageSize: 50,
data: [

View file

@ -5,21 +5,16 @@
* 2.0.
*/
import { useDispatch } from 'react-redux';
import React, { useCallback, useEffect, useMemo, memo } from 'react';
import styled from 'styled-components';
import {
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiFlyoutFooter,
EuiLoadingContent,
EuiTitle,
EuiText,
EuiSpacer,
EuiEmptyPrompt,
EuiToolTip,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { useHistory } from 'react-router-dom';
import { FormattedMessage } from '@kbn/i18n/react';
@ -30,7 +25,6 @@ import {
uiQueryParams,
detailsData,
detailsError,
detailsLoading,
getActivityLogData,
showView,
policyResponseConfigurations,
@ -59,23 +53,12 @@ import { BackToEndpointDetailsFlyoutSubHeader } from './components/back_to_endpo
import { FlyoutBodyNoTopPadding } from './components/flyout_body_no_top_padding';
import { getEndpointListPath } from '../../../../common/routing';
import { ActionsMenu } from './components/actions_menu';
const DetailsFlyoutBody = styled(EuiFlyoutBody)`
overflow-y: hidden;
flex: 1;
.euiFlyoutBody__overflow {
overflow: hidden;
mask-image: none;
}
.euiFlyoutBody__overflowContent {
height: 100%;
display: flex;
}
`;
import { EndpointIndexUIQueryParams } from '../../types';
import { EndpointAction } from '../../store/action';
import { EndpointDetailsFlyoutHeader } from './components/flyout_header';
export const EndpointDetailsFlyout = memo(() => {
const dispatch = useDispatch<(action: EndpointAction) => void>();
const history = useHistory();
const toasts = useToasts();
const queryParams = useEndpointSelector(uiQueryParams);
@ -86,13 +69,24 @@ export const EndpointDetailsFlyout = memo(() => {
const activityLog = useEndpointSelector(getActivityLogData);
const hostDetails = useEndpointSelector(detailsData);
const hostDetailsLoading = useEndpointSelector(detailsLoading);
const hostDetailsError = useEndpointSelector(detailsError);
const policyInfo = useEndpointSelector(policyVersionInfo);
const hostStatus = useEndpointSelector(hostStatusInfo);
const show = useEndpointSelector(showView);
const setFlyoutView = useCallback(
(flyoutView: EndpointIndexUIQueryParams['show']) => {
dispatch({
type: 'endpointDetailsFlyoutTabChanged',
payload: {
flyoutView,
},
});
},
[dispatch]
);
const ContentLoadingMarkup = useMemo(
() => (
<>
@ -133,9 +127,11 @@ export const EndpointDetailsFlyout = memo(() => {
...urlSearchParams,
})
);
}, [history, queryParamsWithoutSelectedEndpoint]);
setFlyoutView(undefined);
}, [setFlyoutView, history, queryParamsWithoutSelectedEndpoint]);
useEffect(() => {
setFlyoutView(show);
if (hostDetailsError !== undefined) {
toasts.addDanger({
title: i18n.translate('xpack.securitySolution.endpoint.details.errorTitle', {
@ -146,7 +142,10 @@ export const EndpointDetailsFlyout = memo(() => {
}),
});
}
}, [hostDetailsError, toasts]);
return () => {
setFlyoutView(undefined);
};
}, [hostDetailsError, setFlyoutView, show, toasts]);
return (
<EuiFlyout
@ -156,22 +155,9 @@ export const EndpointDetailsFlyout = memo(() => {
size="m"
paddingSize="l"
>
<EuiFlyoutHeader>
{hostDetailsLoading ? (
<EuiLoadingContent lines={1} />
) : (
<EuiToolTip content={hostDetails?.host?.hostname} anchorClassName="eui-textTruncate">
<EuiTitle>
<h2
style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}
data-test-subj="endpointDetailsFlyoutTitle"
>
{hostDetails?.host?.hostname}
</h2>
</EuiTitle>
</EuiToolTip>
)}
</EuiFlyoutHeader>
{(show === 'policy_response' || show === 'isolate' || show === 'unisolate') && (
<EndpointDetailsFlyoutHeader hostname={hostDetails?.host?.hostname} />
)}
{hostDetails === undefined ? (
<EuiFlyoutBody>
<EuiLoadingContent lines={3} /> <EuiSpacer size="l" /> <EuiLoadingContent lines={3} />
@ -179,13 +165,11 @@ export const EndpointDetailsFlyout = memo(() => {
) : (
<>
{(show === 'details' || show === 'activity_log') && (
<DetailsFlyoutBody data-test-subj="endpointDetailsFlyoutBody">
<EuiFlexGroup>
<EuiFlexItem>
<EndpointDetailsFlyoutTabs show={show} tabs={tabs} />
</EuiFlexItem>
</EuiFlexGroup>
</DetailsFlyoutBody>
<EndpointDetailsFlyoutTabs
hostname={hostDetails?.host?.hostname}
show={show}
tabs={tabs}
/>
)}
{show === 'policy_response' && <PolicyResponseFlyoutPanel hostMeta={hostDetails} />}

View file

@ -17,6 +17,7 @@ import {
} from '../store/mock_endpoint_result_list';
import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint';
import {
ActivityLog,
HostInfo,
HostPolicyResponse,
HostPolicyResponseActionStatus,
@ -32,12 +33,15 @@ import { KibanaServices, useKibana, useToasts } from '../../../../common/lib/kib
import { hostIsolationHttpMocks } from '../../../../common/lib/endpoint_isolation/mocks';
import { fireEvent } from '@testing-library/dom';
import {
createFailedResourceState,
createLoadedResourceState,
isFailedResourceState,
isLoadedResourceState,
isUninitialisedResourceState,
} from '../../../state';
import { getCurrentIsolationRequestState } from '../store/selectors';
import { licenseService } from '../../../../common/hooks/use_license';
import { FleetActionGenerator } from '../../../../../common/endpoint/data_generators/fleet_action_generator';
// not sure why this can't be imported from '../../../../common/mock/formatted_relative';
// but sure enough it needs to be inline in this one file
@ -625,6 +629,30 @@ describe('when on the endpoint list page', () => {
});
};
const dispatchEndpointDetailsActivityLogChanged = (
dataState: 'failed' | 'success',
data: ActivityLog
) => {
reactTestingLibrary.act(() => {
const getPayload = () => {
switch (dataState) {
case 'failed':
return createFailedResourceState({
statusCode: 500,
error: 'Internal Server Error',
message: 'An internal server error occurred.',
});
case 'success':
return createLoadedResourceState(data);
}
};
store.dispatch({
type: 'endpointDetailsActivityLogChanged',
payload: getPayload(),
});
});
};
beforeEach(async () => {
mockEndpointListApi();
@ -746,6 +774,120 @@ describe('when on the endpoint list page', () => {
expect(renderResult.getByTestId('endpointDetailsActionsButton')).not.toBeNull();
});
describe('when showing Activity Log panel', () => {
let renderResult: ReturnType<typeof render>;
const agentId = 'some_agent_id';
let getMockData: () => ActivityLog;
beforeEach(async () => {
window.IntersectionObserver = jest.fn(() => ({
root: null,
rootMargin: '',
thresholds: [],
takeRecords: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}));
const fleetActionGenerator = new FleetActionGenerator('seed');
const responseData = fleetActionGenerator.generateResponse({
agent_id: agentId,
});
const actionData = fleetActionGenerator.generate({
agents: [agentId],
});
getMockData = () => ({
page: 1,
pageSize: 50,
data: [
{
type: 'response',
item: {
id: 'some_id_0',
data: responseData,
},
},
{
type: 'action',
item: {
id: 'some_id_1',
data: actionData,
},
},
],
});
renderResult = render();
await reactTestingLibrary.act(async () => {
await middlewareSpy.waitForAction('serverReturnedEndpointList');
});
const hostNameLinks = await renderResult.getAllByTestId('hostnameCellLink');
reactTestingLibrary.fireEvent.click(hostNameLinks[0]);
});
afterEach(reactTestingLibrary.cleanup);
it('should show the endpoint details flyout', async () => {
const activityLogTab = await renderResult.findByTestId('activity_log');
reactTestingLibrary.act(() => {
reactTestingLibrary.fireEvent.click(activityLogTab);
});
await middlewareSpy.waitForAction('endpointDetailsActivityLogChanged');
reactTestingLibrary.act(() => {
dispatchEndpointDetailsActivityLogChanged('success', getMockData());
});
const endpointDetailsFlyout = await renderResult.queryByTestId('endpointDetailsFlyoutBody');
expect(endpointDetailsFlyout).not.toBeNull();
});
it('should display log accurately', async () => {
const activityLogTab = await renderResult.findByTestId('activity_log');
reactTestingLibrary.act(() => {
reactTestingLibrary.fireEvent.click(activityLogTab);
});
await middlewareSpy.waitForAction('endpointDetailsActivityLogChanged');
reactTestingLibrary.act(() => {
dispatchEndpointDetailsActivityLogChanged('success', getMockData());
});
const logEntries = await renderResult.queryAllByTestId('timelineEntry');
expect(logEntries.length).toEqual(2);
expect(`${logEntries[0]} .euiCommentTimeline__icon--update`).not.toBe(null);
expect(`${logEntries[1]} .euiCommentTimeline__icon--regular`).not.toBe(null);
});
it('should display empty state when API call has failed', async () => {
const activityLogTab = await renderResult.findByTestId('activity_log');
reactTestingLibrary.act(() => {
reactTestingLibrary.fireEvent.click(activityLogTab);
});
await middlewareSpy.waitForAction('endpointDetailsActivityLogChanged');
reactTestingLibrary.act(() => {
dispatchEndpointDetailsActivityLogChanged('failed', getMockData());
});
const emptyState = await renderResult.queryByTestId('activityLogEmpty');
expect(emptyState).not.toBe(null);
});
it('should display empty state when no log data', async () => {
const activityLogTab = await renderResult.findByTestId('activity_log');
reactTestingLibrary.act(() => {
reactTestingLibrary.fireEvent.click(activityLogTab);
});
await middlewareSpy.waitForAction('endpointDetailsActivityLogChanged');
reactTestingLibrary.act(() => {
dispatchEndpointDetailsActivityLogChanged('success', {
page: 1,
pageSize: 50,
data: [],
});
});
const emptyState = await renderResult.queryByTestId('activityLogEmpty');
expect(emptyState).not.toBe(null);
});
});
describe('when showing host Policy Response panel', () => {
let renderResult: ReturnType<typeof render>;
beforeEach(async () => {

View file

@ -16,6 +16,26 @@ export const ACTIVITY_LOG = {
defaultMessage: 'Activity Log',
}),
LogEntry: {
endOfLog: i18n.translate(
'xpack.securitySolution.endpointDetails.activityLog.logEntry.action.endOfLog',
{
defaultMessage: 'Nothing more to show',
}
),
emptyState: {
title: i18n.translate(
'xpack.securitySolution.endpointDetails.activityLog.logEntry.emptyState.title',
{
defaultMessage: 'No logged actions',
}
),
body: i18n.translate(
'xpack.securitySolution.endpointDetails.activityLog.logEntry.emptyState.body',
{
defaultMessage: 'No actions have been logged for this endpoint.',
}
),
},
action: {
isolatedAction: i18n.translate(
'xpack.securitySolution.endpointDetails.activityLog.logEntry.action.isolated',

View file

@ -56,7 +56,6 @@ export const getAuditLogResponse = async ({
context: SecuritySolutionRequestHandlerContext;
logger: Logger;
}): Promise<{
total: number;
page: number;
pageSize: number;
data: Array<{
@ -96,10 +95,6 @@ export const getAuditLogResponse = async ({
}
return {
total:
typeof result.body.hits.total === 'number'
? result.body.hits.total
: result.body.hits.total.value,
page,
pageSize,
data: result.body.hits.hits.map((e) => ({