[Security Solution][Endpoint] show response actions (Responder) in an entire page overlay (#132973)

- Introduces a new generic component for displaying an entire page overlay (<PageOverlay/>)
- Adds new Responder page overlay. Opens an entire page overlay that displays the Response Actions console (Responder)
- Alters Timeline to:
    - add a CSS class name to the document.body whenever the timeline is opened (in order to support new <PageOverlay/> generic component)
    - store the EUI's theme z-index property in a const for reuse
    - Lifts all Timeline specific EUI overrides (to z-index) into a reusable stylesheet in order for it be re-used in <PageOverlay/>
This commit is contained in:
Paul Tavares 2022-06-09 14:22:52 -04:00 committed by GitHub
parent b0b36d06b0
commit d1fe508f42
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 1119 additions and 660 deletions

View file

@ -334,55 +334,5 @@ exports[`Event Details Overview Cards renders rows and spacers correctly 1`] = `
</div>
</div>
</div>
.c0 {
position: fixed;
bottom: 60px;
left: 20px;
height: 50vh;
width: 48vw;
max-width: 90vw;
}
.c0.is-hidden {
display: none;
}
.c0.is-confirming .modal-content {
opacity: 0.3;
}
.c0 .console-holder {
height: 100%;
}
.c0 .terminate-confirm-panel {
max-width: 85%;
-webkit-box-flex: 0;
-webkit-flex-grow: 0;
-ms-flex-positive: 0;
flex-grow: 0;
}
<div
class="c0 euiModal euiModal--maxWidth-default is-hidden"
data-test-subj="consolePopupWrapper"
>
<div
class="euiModal__flex modal-content"
>
<div
class="euiModalBody"
data-test-subj="consolePopupBody"
>
<div
class="euiModalBody__overflow"
>
<div
class="console-holder"
/>
</div>
</div>
</div>
</div>
</DocumentFragment>
`;

View file

@ -6,7 +6,7 @@
*/
import { EuiBadge, EuiDescriptionList, EuiFlexGroup, EuiIcon } from '@elastic/eui';
import styled, { createGlobalStyle } from 'styled-components';
import styled, { createGlobalStyle, css } from 'styled-components';
import { FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../common/constants';
@ -18,11 +18,13 @@ export const SecuritySolutionAppWrapper = styled.div`
`;
SecuritySolutionAppWrapper.displayName = 'SecuritySolutionAppWrapper';
/*
SIDE EFFECT: the following `createGlobalStyle` overrides default styling in angular code that was not theme-friendly
and `EuiPopover`, `EuiToolTip` global styles
*/
export const AppGlobalStyle = createGlobalStyle<{ theme: { eui: { euiColorPrimary: string } } }>`
/**
* Stylesheet with Eui class overrides in order to address display issues caused when
* the Timeline overlay is opened. These are normally adjustments to ensure that the
* z-index of other EUI components continues to work with the z-index used by timeline
* overlay.
*/
export const TIMELINE_OVERRIDES_CSS_STYLESHEET = () => css`
.euiPopover__panel.euiPopover__panel-isOpen {
z-index: 9900 !important;
min-width: 24px;
@ -34,6 +36,27 @@ export const AppGlobalStyle = createGlobalStyle<{ theme: { eui: { euiColorPrimar
.euiToolTip {
z-index: 9950 !important;
}
/*
overrides the default styling of euiComboBoxOptionsList because it's implemented
as a popover, so it's not selectable as a child of the styled component
*/
.euiComboBoxOptionsList {
z-index: 9999;
}
/* ensure elastic charts tooltips appear above open euiPopovers */
.echTooltip {
z-index: 9950;
}
`;
/*
SIDE EFFECT: the following `createGlobalStyle` overrides default styling in angular code that was not theme-friendly
and `EuiPopover`, `EuiToolTip` global styles
*/
export const AppGlobalStyle = createGlobalStyle<{ theme: { eui: { euiColorPrimary: string } } }>`
${TIMELINE_OVERRIDES_CSS_STYLESHEET}
.euiDataGridRowCell .euiDataGridRowCell__expandActions .euiDataGridRowCell__actionButtonIcon {
display: none;
@ -90,14 +113,6 @@ export const AppGlobalStyle = createGlobalStyle<{ theme: { eui: { euiColorPrimar
}
}
/*
overrides the default styling of euiComboBoxOptionsList because it's implemented
as a popover, so it's not selectable as a child of the styled component
*/
.euiComboBoxOptionsList {
z-index: 9999;
}
/* overrides default styling in angular code that was not theme-friendly */
.euiPanel-loading-hide-border {
border: none;
@ -111,12 +126,6 @@ export const AppGlobalStyle = createGlobalStyle<{ theme: { eui: { euiColorPrimar
}
}
/* ensure elastic charts tooltips appear above open euiPopovers */
.echTooltip {
z-index: 9950;
}
/* applies a "toggled" button style to the Full Screen button */
.${FULL_SCREEN_TOGGLED_CLASS_NAME} {
${({ theme }) => `background-color: ${theme.eui.euiColorPrimary} !important`};

View file

@ -97,55 +97,5 @@ exports[`SessionsView renders correctly against snapshot 1`] = `
</div>
</div>
</div>
.c0 {
position: fixed;
bottom: 60px;
left: 20px;
height: 50vh;
width: 48vw;
max-width: 90vw;
}
.c0.is-hidden {
display: none;
}
.c0.is-confirming .modal-content {
opacity: 0.3;
}
.c0 .console-holder {
height: 100%;
}
.c0 .terminate-confirm-panel {
max-width: 85%;
-webkit-box-flex: 0;
-webkit-flex-grow: 0;
-ms-flex-positive: 0;
flex-grow: 0;
}
<div
class="c0 euiModal euiModal--maxWidth-default is-hidden"
data-test-subj="consolePopupWrapper"
>
<div
class="euiModal__flex modal-content"
>
<div
class="euiModalBody"
data-test-subj="consolePopupBody"
>
<div
class="euiModalBody__overflow"
>
<div
class="console-holder"
/>
</div>
</div>
</div>
</div>
</DocumentFragment>
`;

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import React, { ReactPortal } from 'react';
import { createMemoryHistory, MemoryHistory } from 'history';
import { render as reactRender, RenderOptions, RenderResult } from '@testing-library/react';
import { Action, Reducer, Store } from 'redux';
@ -20,6 +20,7 @@ import {
} from '@testing-library/react-hooks';
import { ReactHooksRenderer, WrapperComponent } from '@testing-library/react-hooks/src/types/react';
import type { UseBaseQueryResult } from 'react-query/types/react/types';
import ReactDOM from 'react-dom';
import { ConsoleManager } from '../../../management/components/console';
import type { StartPlugins, StartServices } from '../../../types';
import { depsStartMock } from './dependencies_start_mock';
@ -36,6 +37,41 @@ import { KibanaContextProvider, KibanaServices } from '../../lib/kibana';
import { getDeepLinks } from '../../../app/deep_links';
import { fleetGetPackageListHttpMock } from '../../../management/mocks';
const REAL_REACT_DOM_CREATE_PORTAL = ReactDOM.createPortal;
/**
* Resets the mock that is applied to `createPortal()` by default.
* **IMPORTANT** : Make sure you call this function from a `before*()` or `after*()` callback
*
* @example
*
* // Turn off for test using Enzyme
* beforeAll(() => resetReactDomCreatePortalMock());
*/
export const resetReactDomCreatePortalMock = () => {
ReactDOM.createPortal = REAL_REACT_DOM_CREATE_PORTAL;
};
beforeAll(() => {
// Mocks the React DOM module to ensure compatibility with react-testing-library and avoid
// error like:
// ```
// TypeError: parentInstance.children.indexOf is not a function
// at appendChild (node_modules/react-test-renderer/cjs/react-test-renderer.development.js:7183:39)
// ```
// @see https://github.com/facebook/react/issues/11565
ReactDOM.createPortal = jest.fn((...args) => {
REAL_REACT_DOM_CREATE_PORTAL(...args);
// Needed for react-Test-library. See:
// https://github.com/facebook/react/issues/11565
return args[0] as ReactPortal;
});
});
afterAll(() => {
resetReactDomCreatePortalMock();
});
export type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult;
/**

View file

@ -6,8 +6,18 @@
*/
import { find, getOr, some } from 'lodash/fp';
import { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import { Ecs } from '../../../common/ecs';
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import type { Ecs } from '../../../common/ecs';
/**
* Check to see if a timeline event item is an Alert (vs an event)
* @param timelineEventItem
*/
export const isTimelineEventItemAnAlert = (
timelineEventItem: TimelineEventsDetailsItem[]
): boolean => {
return some({ category: 'kibana', field: 'kibana.alert.rule.uuid' }, timelineEventItem);
};
/**
* Checks to see if the given set of Timeline event detail items includes data that indicates its
@ -19,9 +29,7 @@ export const isAlertFromEndpointEvent = ({
}: {
data: TimelineEventsDetailsItem[];
}): boolean => {
const isAlert = some({ category: 'kibana', field: 'kibana.alert.rule.uuid' }, data);
if (!isAlert) {
if (!isTimelineEventItemAnAlert(data)) {
return false;
}

View file

@ -5,6 +5,4 @@
* 2.0.
*/
export interface EndpointCommandDefinitionMeta {
endpointId: string;
}
export { useResponderActionItem } from './use_responder_action_item';

View file

@ -9,10 +9,7 @@ import { EuiContextMenuItem } from '@elastic/eui';
import React, { memo, ReactNode, useCallback, useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import {
useGetEndpointDetails,
useShowEndpointResponseActionsConsole,
} from '../../../management/hooks';
import { useGetEndpointDetails, useWithShowEndpointResponder } from '../../../management/hooks';
import { HostStatus } from '../../../../common/endpoint/types';
export const NOT_FROM_ENDPOINT_HOST_TOOLTIP = i18n.translate(
@ -28,14 +25,14 @@ export const LOADING_ENDPOINT_DATA_TOOLTIP = i18n.translate(
{ defaultMessage: 'Loading' }
);
export interface ResponseActionsConsoleContextMenuItemProps {
export interface ResponderContextMenuItemProps {
endpointId: string;
onClick?: () => void;
}
export const ResponseActionsConsoleContextMenuItem =
memo<ResponseActionsConsoleContextMenuItemProps>(({ endpointId, onClick }) => {
const showEndpointResponseActionsConsole = useShowEndpointResponseActionsConsole();
export const ResponderContextMenuItem = memo<ResponderContextMenuItemProps>(
({ endpointId, onClick }) => {
const showEndpointResponseActionsConsole = useWithShowEndpointResponder();
const {
data: endpointHostInfo,
isFetching,
@ -85,5 +82,6 @@ export const ResponseActionsConsoleContextMenuItem =
/>
</EuiContextMenuItem>
);
});
ResponseActionsConsoleContextMenuItem.displayName = 'ResponseActionsConsoleContextMenuItem';
}
);
ResponderContextMenuItem.displayName = 'ResponderContextMenuItem';

View file

@ -8,12 +8,15 @@
import React, { useMemo } from 'react';
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import { useUserPrivileges } from '../../../common/components/user_privileges';
import { isAlertFromEndpointEvent } from '../../../common/utils/endpoint_alert_check';
import { ResponseActionsConsoleContextMenuItem } from './response_actions_console_context_menu_item';
import {
isAlertFromEndpointEvent,
isTimelineEventItemAnAlert,
} from '../../../common/utils/endpoint_alert_check';
import { ResponderContextMenuItem } from './responder_context_menu_item';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { getFieldValue } from '../host_isolation/helpers';
export const useResponseActionsConsoleActionItem = (
export const useResponderActionItem = (
eventDetailsData: TimelineEventsDetailsItem[] | null,
onClick: () => void
): JSX.Element[] => {
@ -23,6 +26,10 @@ export const useResponseActionsConsoleActionItem = (
const { loading: isAuthzLoading, canAccessEndpointManagement } =
useUserPrivileges().endpointPrivileges;
const isAlert = useMemo(() => {
return isTimelineEventItemAnAlert(eventDetailsData || []);
}, [eventDetailsData]);
const isEndpointAlert = useMemo(() => {
return isAlertFromEndpointEvent({ data: eventDetailsData || [] });
}, [eventDetailsData]);
@ -35,9 +42,14 @@ export const useResponseActionsConsoleActionItem = (
return useMemo(() => {
const actions: JSX.Element[] = [];
if (isResponseActionsConsoleEnabled && !isAuthzLoading && canAccessEndpointManagement) {
if (
isResponseActionsConsoleEnabled &&
!isAuthzLoading &&
canAccessEndpointManagement &&
isAlert
) {
actions.push(
<ResponseActionsConsoleContextMenuItem
<ResponderContextMenuItem
endpointId={isEndpointAlert ? endpointId : ''}
onClick={onClick}
/>
@ -48,6 +60,7 @@ export const useResponseActionsConsoleActionItem = (
}, [
canAccessEndpointManagement,
endpointId,
isAlert,
isAuthzLoading,
isEndpointAlert,
isResponseActionsConsoleEnabled,

View file

@ -24,7 +24,7 @@ import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_exper
import {
HOST_ENDPOINT_UNENROLLED_TOOLTIP,
NOT_FROM_ENDPOINT_HOST_TOOLTIP,
} from '../response_actions_console/response_actions_console_context_menu_item';
} from '../endpoint_responder/responder_context_menu_item';
import { endpointMetadataHttpMocks } from '../../../management/pages/endpoint_hosts/mocks';
import { HttpSetup } from '@kbn/core/public';
import {
@ -59,7 +59,11 @@ jest.mock('../../../common/hooks/use_experimental_features', () => ({
}));
jest.mock('../../../common/utils/endpoint_alert_check', () => {
const realEndpointAlertCheckUtils = jest.requireActual(
'../../../common/utils/endpoint_alert_check'
);
return {
isTimelineEventItemAnAlert: realEndpointAlertCheckUtils.isTimelineEventItemAnAlert,
isAlertFromEndpointAlert: jest.fn().mockReturnValue(true),
isAlertFromEndpointEvent: jest.fn().mockReturnValue(true),
};
@ -431,6 +435,13 @@ describe('take action dropdown', () => {
expect(findLaunchResponderButton()).toHaveLength(0);
});
it('should not display the button for Events', async () => {
setAlertDetailsDataMockToEvent();
render();
expect(findLaunchResponderButton()).toHaveLength(0);
});
it('should disable the button if alert NOT from a host running endpoint', async () => {
setTypeOnEcsDataWithAgentType('filebeat');
if (defaultProps.detailsData) {

View file

@ -8,7 +8,7 @@
import React, { useCallback, useMemo, useState } from 'react';
import { EuiButton, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types';
import { useResponseActionsConsoleActionItem } from '../response_actions_console';
import { useResponderActionItem } from '../endpoint_responder';
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
import { TAKE_ACTION } from '../alerts_table/alerts_utility_bar/translations';
import { useExceptionActions } from '../alerts_table/timeline_actions/use_add_exception_actions';
@ -136,7 +136,7 @@ export const TakeActionDropdown = React.memo(
isHostIsolationPanelOpen,
});
const endpointResponseActionsConsoleItems = useResponseActionsConsoleActionItem(
const endpointResponseActionsConsoleItems = useResponderActionItem(
detailsData,
closePopoverHandler
);

View file

@ -1,88 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiFocusTrap,
EuiPanel,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
export interface ConfirmTerminateProps {
onConfirm: () => void;
onCancel: () => void;
}
export const ConfirmTerminate = memo<ConfirmTerminateProps>(({ onConfirm, onCancel }) => {
return (
<div
className="euiOverlayMask"
style={{ position: 'absolute' }}
data-test-subj="consolePopupTerminateConfirmModal"
>
<EuiFocusTrap>
<EuiPanel className="terminate-confirm-panel">
<EuiCallOut
color="primary"
iconType="iInCircle"
data-test-subj="consolePopupTerminateConfirmMessage"
title={
<FormattedMessage
id="xpack.securitySolution.console.popup.confirmTitle"
defaultMessage="Terminate this session"
/>
}
>
<EuiText>
<FormattedMessage
id="xpack.securitySolution.console.popup.terminateMessage"
defaultMessage="This will end your console session. Do you wish to continue?"
/>
</EuiText>
</EuiCallOut>
<EuiSpacer />
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
onClick={onCancel}
data-test-subj="consolePopupTerminateModalCancelButton"
>
<FormattedMessage
id="xpack.securitySolution.console.popup.terminateConfirmCancelLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
onClick={onConfirm}
color="danger"
fill
data-test-subj="consolePopupTerminateModalTerminateButton"
>
<FormattedMessage
id="xpack.securitySolution.console.popup.terminateConfirmSubmitLabel"
defaultMessage="Terminate"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFocusTrap>
</div>
);
});
ConfirmTerminate.displayName = 'ConfirmTerminate';

View file

@ -0,0 +1,94 @@
/*
* 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, ReactNode, useCallback, MouseEventHandler, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButton, EuiButtonEmpty } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { PageLayout, PageLayoutProps } from './page_layout';
import { useTestIdGenerator } from '../../../../../hooks/use_test_id_generator';
import { PageOverlay } from '../../../../page_overlay/page_overlay';
const BACK_LABEL = i18n.translate('xpack.securitySolution.consolePageOverlay.backButtonLabel', {
defaultMessage: 'Return to page content',
});
export interface ConsolePageOverlayProps {
console: ReactNode;
isHidden: boolean;
onHide: () => void;
pageTitle?: ReactNode;
body?: ReactNode;
actions?: ReactNode[];
}
export const ConsolePageOverlay = memo<ConsolePageOverlayProps>(
({ console, onHide, isHidden, body, actions, pageTitle = '' }) => {
const getTestId = useTestIdGenerator('consolePageOverlay');
const handleCloseOverlayOnClick: MouseEventHandler = useCallback(
(ev) => {
ev.preventDefault();
onHide();
},
[onHide]
);
const layoutProps = useMemo<PageLayoutProps>(() => {
// If in `hidden` mode, then we don't render the html for the layout header section
// of the layout
if (isHidden) return {};
return {
pageTitle,
headerHasBottomBorder: false,
'data-test-subj': getTestId('layout'),
headerBackComponent: (
<EuiButtonEmpty
flush="left"
size="xs"
iconType="arrowLeft"
onClick={handleCloseOverlayOnClick}
>
{BACK_LABEL}
</EuiButtonEmpty>
),
actions: [
<EuiButton
fill
onClick={handleCloseOverlayOnClick}
minWidth="auto"
data-test-subj={getTestId('doneButton')}
>
<FormattedMessage
id="xpack.securitySolution.consolePageOverlay.doneButtonLabel"
defaultMessage="Done"
/>
</EuiButton>,
...(actions ?? []),
],
};
}, [actions, getTestId, handleCloseOverlayOnClick, isHidden, pageTitle]);
return (
<PageOverlay
isHidden={isHidden}
data-test-subj="consolePageOverlay"
onHide={onHide}
paddingSize="xl"
enableScrolling={false}
>
<PageLayout {...layoutProps}>
{body}
{console}
</PageLayout>
</PageOverlay>
);
}
);
ConsolePageOverlay.displayName = 'ConsolePageOverlay';

View file

@ -1,136 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo, PropsWithChildren, ReactNode, useCallback, useMemo, useState } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiIcon,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
} from '@elastic/eui';
import styled from 'styled-components';
import { FormattedMessage } from '@kbn/i18n-react';
import classNames from 'classnames';
import { ConfirmTerminate } from './confirm_terminate';
const ConsolePopupWrapper = styled.div`
position: fixed;
bottom: 60px;
left: 20px;
height: 50vh;
width: 48vw;
max-width: 90vw;
&.is-hidden {
display: none;
}
&.is-confirming .modal-content {
opacity: 0.3;
}
.console-holder {
height: 100%;
}
.terminate-confirm-panel {
max-width: 85%;
flex-grow: 0;
}
`;
type ConsolePopupProps = PropsWithChildren<{
isHidden: boolean;
onTerminate: () => void;
onHide: () => void;
title?: ReactNode;
}>;
export const ConsolePopup = memo<ConsolePopupProps>(
({ children, isHidden, title = '', onTerminate, onHide }) => {
const [showTerminateConfirm, setShowTerminateConfirm] = useState(false);
const cssClassNames = useMemo(() => {
return classNames({
euiModal: true,
'euiModal--maxWidth-default': true,
'is-hidden': isHidden,
'is-confirming': showTerminateConfirm,
});
}, [isHidden, showTerminateConfirm]);
const handleTerminateOnClick = useCallback(() => {
setShowTerminateConfirm(true);
}, []);
const handleTerminateOnConfirm = useCallback(() => {
setShowTerminateConfirm(false);
onTerminate();
}, [onTerminate]);
const handleTerminateOnCancel = useCallback(() => {
setShowTerminateConfirm(false);
}, []);
return (
<ConsolePopupWrapper className={cssClassNames} data-test-subj="consolePopupWrapper">
<div className="euiModal__flex modal-content">
{!isHidden && (
<EuiModalHeader data-test-subj="consolePopupHeader">
<EuiModalHeaderTitle>
<h1>
<EuiIcon type="console" size="xl" /> {title}
</h1>
</EuiModalHeaderTitle>
</EuiModalHeader>
)}
{/*
IMPORTANT: The Modal body (below) is always shown. This is how the command history
of each command is persisted - by allowing the consoles to still be
rendered (Console takes care of hiding it own UI in this case)
*/}
<EuiModalBody data-test-subj="consolePopupBody">
<div className="console-holder">{children}</div>
</EuiModalBody>
{!isHidden && (
<EuiModalFooter>
<EuiButtonEmpty
color="danger"
onClick={handleTerminateOnClick}
data-test-subj="consolePopupTerminateButton"
>
<FormattedMessage
id="xpack.securitySolution.console.manager.popup.terminateLabel"
defaultMessage="Terminate"
/>
</EuiButtonEmpty>
<EuiButton onClick={onHide} fill data-test-subj="consolePopupHideButton">
<FormattedMessage
id="xpack.securitySolution.console.manager.popup.hideLabel"
defaultMessage="hide"
/>
</EuiButton>
</EuiModalFooter>
)}
</div>
{!isHidden && showTerminateConfirm && (
<ConfirmTerminate
onConfirm={handleTerminateOnConfirm}
onCancel={handleTerminateOnCancel}
/>
)}
</ConsolePopupWrapper>
);
}
);
ConsolePopup.displayName = 'ConsolePopup';

View file

@ -0,0 +1,138 @@
/*
* 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, PropsWithChildren, ReactNode, useMemo } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiPageHeader,
EuiPanel,
EuiPanelProps,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import styled from 'styled-components';
import classnames from 'classnames';
import { EuiPageHeaderProps } from '@elastic/eui/src/components/page/page_header/page_header';
import { useTestIdGenerator } from '../../../../../hooks/use_test_id_generator';
const EuiPanelStyled = styled(EuiPanel)`
&.full-height,
.full-height {
height: 100%;
}
.is-not-scrollable {
overflow: hidden;
}
.is-scrollable {
overflow: auto;
}
`;
export type PageLayoutProps = PropsWithChildren<{
pageTitle?: ReactNode;
pageDescription?: ReactNode;
actions?: ReactNode | ReactNode[];
headerHasBottomBorder?: boolean;
restrictWidth?: boolean | number | string;
paddingSize?: EuiPanelProps['paddingSize'];
scrollableBody?: boolean;
headerBackComponent?: ReactNode;
'data-test-subj'?: string;
}>;
export const PageLayout = memo<PageLayoutProps>(
({
pageTitle,
pageDescription,
actions,
headerHasBottomBorder,
restrictWidth,
paddingSize = 'l',
scrollableBody = false,
headerBackComponent,
children,
'data-test-subj': dataTestSubj,
}) => {
const hideHeader = !pageTitle && !pageDescription && !actions && !headerBackComponent;
const getTestId = useTestIdGenerator(dataTestSubj);
const headerRightSideItems = useMemo(() => {
return Array.isArray(actions) ? actions : actions ? [actions] : undefined;
}, [actions]);
const headerRightSideGroupProps = useMemo<EuiPageHeaderProps['rightSideGroupProps']>(() => {
return {
gutterSize: 'm',
};
}, []);
const bodyClassName = useMemo(() => {
return classnames({
'is-scrollable': scrollableBody,
'is-not-scrollable': !scrollableBody,
'full-height': true,
});
}, [scrollableBody]);
const headerTitleContainer = useMemo(() => {
return hideHeader ? null : (
<EuiFlexGroup direction="column" gutterSize="none" alignItems="flexStart" wrap={false}>
{headerBackComponent && <EuiFlexItem grow={false}>{headerBackComponent}</EuiFlexItem>}
<EuiFlexItem grow={false}>
<EuiTitle size="l">
<span data-test-subj={getTestId('titleHolder')}>{pageTitle}</span>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
);
}, [getTestId, headerBackComponent, hideHeader, pageTitle]);
return (
<EuiPanelStyled
hasShadow={false}
paddingSize={paddingSize}
data-test-subj={dataTestSubj}
className="full-height"
color="transparent"
>
<EuiFlexGroup
direction="column"
responsive={false}
gutterSize="none"
className="full-height"
data-test-subj={getTestId('root')}
>
{!hideHeader && (
<EuiFlexItem grow={false} data-test-subj={getTestId('headerContainer')}>
<EuiPageHeader
pageTitle={headerTitleContainer}
description={pageDescription}
bottomBorder={headerHasBottomBorder}
rightSideItems={headerRightSideItems}
rightSideGroupProps={headerRightSideGroupProps}
restrictWidth={restrictWidth}
data-test-subj={getTestId('header')}
/>
<EuiSpacer size="l" />
</EuiFlexItem>
)}
<EuiFlexItem grow className={bodyClassName} data-test-subj={getTestId('body')}>
<div role="main" className="full-height">
{children}
</div>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanelStyled>
);
}
);
PageLayout.displayName = 'PageLayout';

View file

@ -6,8 +6,8 @@
*/
import { renderHook as _renderHook, RenderHookResult, act } from '@testing-library/react-hooks';
import { ConsoleManager, useConsoleManager } from './console_manager';
import React, { memo } from 'react';
import { useConsoleManager } from './console_manager';
import React from 'react';
import type {
ConsoleManagerClient,
ConsoleRegistrationInterface,
@ -17,7 +17,11 @@ import {
AppContextTestRender,
createAppRootMockRenderer,
} from '../../../../../common/mock/endpoint';
import { ConsoleManagerTestComponent, getNewConsoleRegistrationMock } from './mocks';
import {
ConsoleManagerTestComponent,
getConsoleManagerMockRenderResultQueriesAndActions,
getNewConsoleRegistrationMock,
} from './mocks';
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/react';
import { enterConsoleCommand } from '../../mocks';
@ -42,18 +46,9 @@ describe('When using ConsoleManager', () => {
beforeEach(() => {
const { AppWrapper } = createAppRootMockRenderer();
const RenderWrapper = memo(({ children }) => {
return (
<AppWrapper>
<ConsoleManager>{children}</ConsoleManager>
</AppWrapper>
);
});
RenderWrapper.displayName = 'RenderWrapper';
renderHook = () => {
renderResult = _renderHook(useConsoleManager, {
wrapper: RenderWrapper,
wrapper: AppWrapper,
});
return renderResult;
@ -83,7 +78,6 @@ describe('When using ConsoleManager', () => {
expect(renderResult.result.current.getOne(newConsole.id)).toEqual({
id: newConsole.id,
title: newConsole.title,
meta: newConsole.meta,
show: expect.any(Function),
hide: expect.any(Function),
@ -147,17 +141,6 @@ describe('When using ConsoleManager', () => {
expect(renderResult.result.current.getOne(consoleId)).toBeUndefined();
});
it('should call `onBeforeTerminate()`', () => {
renderHook();
const { id: consoleId, onBeforeTerminate } = registerNewConsole();
act(() => {
renderResult.result.current.terminate(consoleId);
});
expect(onBeforeTerminate).toHaveBeenCalled();
});
it('should throw if attempting to terminate a console with invalid `id`', () => {
renderHook();
@ -188,7 +171,6 @@ describe('When using ConsoleManager', () => {
expect(registeredConsole).toEqual({
id: expect.any(String),
meta: expect.any(Object),
title: expect.anything(),
show: expect.any(Function),
hide: expect.any(Function),
terminate: expect.any(Function),
@ -222,27 +204,16 @@ describe('When using ConsoleManager', () => {
});
});
describe('and when the console popup is rendered into the page', () => {
describe('and when the console page overlay is rendered into the page', () => {
type ConsoleManagerQueriesAndActions = ReturnType<
typeof getConsoleManagerMockRenderResultQueriesAndActions
>;
let render: () => Promise<ReturnType<AppContextTestRender['render']>>;
let renderResult: ReturnType<AppContextTestRender['render']>;
const clickOnRegisterNewConsole = () => {
act(() => {
userEvent.click(renderResult.getByTestId('registerNewConsole'));
});
};
const openRunningConsole = async () => {
act(() => {
userEvent.click(renderResult.queryAllByTestId('showRunningConsole')[0]);
});
await waitFor(() => {
expect(
renderResult.getByTestId('consolePopupWrapper').classList.contains('is-hidden')
).toBe(false);
});
};
let clickOnRegisterNewConsole: ConsoleManagerQueriesAndActions['clickOnRegisterNewConsole'];
let openRunningConsole: ConsoleManagerQueriesAndActions['openRunningConsole'];
let hideOpenedConsole: ConsoleManagerQueriesAndActions['hideOpenedConsole'];
beforeEach(() => {
const mockedContext = createAppRootMockRenderer();
@ -250,7 +221,10 @@ describe('When using ConsoleManager', () => {
render = async () => {
renderResult = mockedContext.render(<ConsoleManagerTestComponent />);
clickOnRegisterNewConsole();
({ clickOnRegisterNewConsole, openRunningConsole, hideOpenedConsole } =
getConsoleManagerMockRenderResultQueriesAndActions(renderResult));
await clickOnRegisterNewConsole();
await waitFor(() => {
expect(renderResult.queryAllByTestId('runningConsole').length).toBeGreaterThan(0);
@ -265,7 +239,9 @@ describe('When using ConsoleManager', () => {
it('should show the title', async () => {
await render();
expect(renderResult.getByTestId('consolePopupHeader').textContent).toMatch(/Test console/);
expect(renderResult.getByTestId('consolePageOverlay-layout-titleHolder').textContent).toMatch(
/Test console/
);
});
it('should show the console', async () => {
@ -274,27 +250,17 @@ describe('When using ConsoleManager', () => {
expect(renderResult.getByTestId('testRunningConsole')).toBeTruthy();
});
it('should show `terminate` button', async () => {
it('should show `Done` button', async () => {
await render();
expect(renderResult.getByTestId('consolePopupTerminateButton')).toBeTruthy();
expect(renderResult.getByTestId('consolePageOverlay-doneButton')).toBeTruthy();
});
it('should show `hide` button', async () => {
it('should hide the console page overlay', async () => {
await render();
userEvent.click(renderResult.getByTestId('consolePageOverlay-doneButton'));
expect(renderResult.getByTestId('consolePopupHideButton')).toBeTruthy();
});
it('should hide the console popup', async () => {
await render();
userEvent.click(renderResult.getByTestId('consolePopupHideButton'));
await waitFor(() => {
expect(
renderResult.getByTestId('consolePopupWrapper').classList.contains('is-hidden')
).toBe(true);
});
expect(renderResult.queryByTestId('consolePageOverlay')).toBeNull();
});
it("should persist a console's command output history on hide/show", async () => {
@ -306,13 +272,7 @@ describe('When using ConsoleManager', () => {
expect(renderResult.queryAllByTestId('testRunningConsole-historyItem')).toHaveLength(2);
});
// Hide the console
userEvent.click(renderResult.getByTestId('consolePopupHideButton'));
await waitFor(() => {
expect(
renderResult.getByTestId('consolePopupWrapper').classList.contains('is-hidden')
).toBe(true);
});
await hideOpenedConsole();
// Open the console back up and ensure prior items still there
await openRunningConsole();
@ -343,13 +303,7 @@ describe('When using ConsoleManager', () => {
expectedStoreValue
);
// Hide the console
userEvent.click(renderResult.getByTestId('consolePopupHideButton'));
await waitFor(() => {
expect(
renderResult.getByTestId('consolePopupWrapper').classList.contains('is-hidden')
).toBe(true);
});
await hideOpenedConsole();
// Open the console back up and ensure `status` and `store` are the last set of values
await openRunningConsole();
@ -361,47 +315,5 @@ describe('When using ConsoleManager', () => {
expectedStoreValue
);
});
describe('and the terminate confirmation is shown', () => {
const clickOnTerminateButton = async () => {
userEvent.click(renderResult.getByTestId('consolePopupTerminateButton'));
await waitFor(() => {
expect(renderResult.getByTestId('consolePopupTerminateConfirmModal')).toBeTruthy();
});
};
beforeEach(async () => {
await render();
await clickOnTerminateButton();
});
it('should show confirmation when terminate button is clicked', async () => {
expect(renderResult.getByTestId('consolePopupTerminateConfirmMessage')).toBeTruthy();
});
it('should show cancel and terminate buttons', async () => {
expect(renderResult.getByTestId('consolePopupTerminateModalCancelButton')).toBeTruthy();
expect(renderResult.getByTestId('consolePopupTerminateModalTerminateButton')).toBeTruthy();
});
it('should hide the confirmation when cancel is clicked', async () => {
userEvent.click(renderResult.getByTestId('consolePopupTerminateModalCancelButton'));
await waitFor(() => {
expect(renderResult.queryByTestId('consolePopupTerminateConfirmModal')).toBeNull();
});
});
it('should terminate when terminate is clicked', async () => {
userEvent.click(renderResult.getByTestId('consolePopupTerminateModalTerminateButton'));
await waitFor(() => {
expect(
renderResult.getByTestId('consolePopupWrapper').classList.contains('is-hidden')
).toBe(true);
});
});
});
});
});

View file

@ -14,7 +14,8 @@ import React, {
useRef,
useState,
} from 'react';
import { ConsolePopup } from './components/console_popup';
import type { ConsoleDataState } from '../console_state/types';
import { ConsolePageOverlay } from './components/console_page_overlay';
import {
ConsoleManagerClient,
ConsoleRegistrationInterface,
@ -22,13 +23,15 @@ import {
} from './types';
import { Console } from '../../console';
interface ManagedConsole {
interface ManagedConsole
extends Pick<
ConsoleRegistrationInterface,
'consoleProps' | 'PageTitleComponent' | 'PageBodyComponent' | 'ActionComponents'
> {
client: RegisteredConsoleClient;
consoleProps: ConsoleRegistrationInterface['consoleProps'];
console: JSX.Element; // actual console component
isOpen: boolean;
key: symbol;
onBeforeTerminate?: ConsoleRegistrationInterface['onBeforeTerminate'];
}
type RunningConsoleStorage = Record<string, ManagedConsole>;
@ -39,6 +42,12 @@ interface ConsoleManagerInternalClient {
* @param key
*/
getManagedConsole(key: ManagedConsole['key']): ManagedConsole | undefined;
/** Returns the Console's internal state (if any) */
getManagedConsoleState(key: ManagedConsole['key']): ConsoleDataState | undefined;
/** Stores the console's internal state */
storeManagedConsoleState(key: ManagedConsole['key'], state: ConsoleDataState): void;
}
interface ConsoleManagerContextClients {
@ -60,6 +69,7 @@ export type ConsoleManagerProps = PropsWithChildren<{
*/
export const ConsoleManager = memo<ConsoleManagerProps>(({ storage = {}, children }) => {
const [consoleStorage, setConsoleStorage] = useState<RunningConsoleStorage>(storage);
const [consoleStateStorage] = useState(new Map<ManagedConsole['key'], ConsoleDataState>());
// `consoleStorageRef` keeps a copy (reference) to the latest copy of the `consoleStorage` so that
// some exposed methods (ex. `RegisteredConsoleClient`) are guaranteed to be immutable and function
@ -123,12 +133,6 @@ export const ConsoleManager = memo<ConsoleManagerProps>(({ storage = {}, childre
validateIdOrThrow(id);
setConsoleStorage((prevState) => {
const { onBeforeTerminate } = prevState[id];
if (onBeforeTerminate) {
onBeforeTerminate();
}
const newState = { ...prevState };
delete newState[id];
@ -153,7 +157,7 @@ export const ConsoleManager = memo<ConsoleManagerProps>(({ storage = {}, childre
return Object.values(consoleStorage).map(
(managedConsole) => managedConsole.client
) as ReadonlyArray<Readonly<RegisteredConsoleClient<Meta>>>;
}, [consoleStorage]); // << This callack should always use `consoleStorage`
}, [consoleStorage]); // << This callback should always use `consoleStorage`
const isVisible = useCallback((id: string): boolean => {
if (consoleStorageRef.current?.[id]) {
@ -164,7 +168,7 @@ export const ConsoleManager = memo<ConsoleManagerProps>(({ storage = {}, childre
}, []); // << IMPORTANT: this callback should have no dependencies
const register = useCallback<ConsoleManagerClient['register']>(
({ id, title, meta, consoleProps, ...otherRegisterProps }) => {
({ id, meta, consoleProps, ...otherRegisterProps }) => {
if (consoleStorage[id]) {
throw new Error(`Console with id ${id} already registered`);
}
@ -178,10 +182,12 @@ export const ConsoleManager = memo<ConsoleManagerProps>(({ storage = {}, childre
const isThisConsoleVisible = isVisible.bind(null, id);
const managedConsole: ManagedConsole = {
PageBodyComponent: undefined,
PageTitleComponent: undefined,
ActionComponents: undefined,
...otherRegisterProps,
client: {
id,
title,
meta,
// The use of `setTimeout()` below is needed because this client interface can be consumed
// prior to the component state being updated. Placing a delay on the execution of these
@ -230,23 +236,30 @@ export const ConsoleManager = memo<ConsoleManagerProps>(({ storage = {}, childre
const consoleManageContextClients = useMemo<ConsoleManagerContextClients>(() => {
return {
client: consoleManagerClient,
internal: {
getManagedConsole(key): ManagedConsole | undefined {
return Object.values(consoleStorage).find((managedConsole) => managedConsole.key === key);
},
getManagedConsoleState(key: ManagedConsole['key']): ConsoleDataState | undefined {
return consoleStateStorage.get(key);
},
storeManagedConsoleState(key: ManagedConsole['key'], state: ConsoleDataState) {
consoleStateStorage.set(key, state);
},
},
};
}, [consoleManagerClient, consoleStorage]);
}, [consoleManagerClient, consoleStateStorage, consoleStorage]);
const visibleConsole = useMemo(() => {
return Object.values(consoleStorage).find((managedConsole) => managedConsole.isOpen);
}, [consoleStorage]);
const handleOnTerminate = useCallback(() => {
if (visibleConsole) {
consoleManagerClient.terminate(visibleConsole.client.id);
}
}, [consoleManagerClient, visibleConsole]);
const visibleConsoleMeta = useMemo(() => {
return visibleConsole?.client.meta ?? {};
}, [visibleConsole?.client.meta]);
const handleOnHide = useCallback(() => {
if (visibleConsole) {
@ -254,22 +267,39 @@ export const ConsoleManager = memo<ConsoleManagerProps>(({ storage = {}, childre
}
}, [consoleManagerClient, visibleConsole]);
const runningConsoles = useMemo(() => {
return Object.values(consoleStorage).map((managedConsole) => managedConsole.console);
}, [consoleStorage]);
return (
<ConsoleManagerContext.Provider value={consoleManageContextClients}>
{children}
<ConsolePopup
title={visibleConsole?.client.title}
isHidden={!visibleConsole}
onTerminate={handleOnTerminate}
onHide={handleOnHide}
>
{runningConsoles}
</ConsolePopup>
{visibleConsole && (
<ConsolePageOverlay
onHide={handleOnHide}
console={
<Console
{...visibleConsole.consoleProps}
managedKey={visibleConsole.key}
key={visibleConsole.client.id}
/>
}
isHidden={!visibleConsole}
pageTitle={
visibleConsole.PageTitleComponent && (
<visibleConsole.PageTitleComponent meta={visibleConsoleMeta} />
)
}
body={
visibleConsole.PageBodyComponent && (
<visibleConsole.PageBodyComponent meta={visibleConsoleMeta} />
)
}
actions={
visibleConsole.ActionComponents &&
visibleConsole.ActionComponents.map((ActionComponent) => {
return <ActionComponent meta={visibleConsoleMeta} />;
})
}
/>
)}
</ConsoleManagerContext.Provider>
);
});
@ -304,3 +334,39 @@ export const useWithManagedConsole = (
return consoleManagerClients.internal.getManagedConsole(key);
}
};
type WithManagedConsoleState = Readonly<
[
getState: undefined | (() => ConsoleDataState | undefined),
storeState: undefined | ((state: ConsoleDataState) => void)
]
>;
/**
* Provides methods for retrieving/storing a console's internal state (if any)
* @param key
*/
export const useWithManagedConsoleState = (
key: ManagedConsole['key'] | undefined
): WithManagedConsoleState => {
const consoleManagerClients = useContext(ConsoleManagerContext);
return useMemo(() => {
if (!key || !consoleManagerClients) {
return [undefined, undefined];
}
return [
// getState()
() => {
return consoleManagerClients.internal.getManagedConsoleState(key);
},
// storeState()
(state: ConsoleDataState) => {
if (consoleManagerClients.internal.getManagedConsole(key)) {
consoleManagerClients.internal.storeManagedConsoleState(key, state);
}
},
];
}, [consoleManagerClients, key]);
};

View file

@ -9,7 +9,6 @@
import React, { memo, useCallback } from 'react';
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { act } from '@testing-library/react-hooks';
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/react';
import { AppContextTestRender } from '../../../../../common/mock/endpoint';
@ -22,13 +21,12 @@ export const getNewConsoleRegistrationMock = (
): ConsoleRegistrationInterface => {
return {
id: Math.random().toString(36),
title: 'Test console',
PageTitleComponent: () => <>{'Test console'}</>,
meta: { about: 'for unit testing ' },
consoleProps: {
'data-test-subj': 'testRunningConsole',
commands: getCommandListMock(),
},
onBeforeTerminate: jest.fn(),
...overrides,
};
};
@ -48,9 +46,7 @@ export const getConsoleManagerMockRenderResultQueriesAndActions = (
clickOnRegisterNewConsole: async () => {
const currentRunningCount = renderResult.queryAllByTestId('showRunningConsole').length;
act(() => {
userEvent.click(renderResult.getByTestId('registerNewConsole'));
});
userEvent.click(renderResult.getByTestId('registerNewConsole'));
await waitFor(() => {
expect(renderResult.queryAllByTestId('showRunningConsole')).toHaveLength(
@ -64,29 +60,24 @@ export const getConsoleManagerMockRenderResultQueriesAndActions = (
* @param atIndex
*/
openRunningConsole: async (atIndex: number = 0) => {
act(() => {
userEvent.click(renderResult.queryAllByTestId('showRunningConsole')[atIndex]);
});
const runningConsoleShowButton = renderResult.queryAllByTestId('showRunningConsole')[atIndex];
if (!runningConsoleShowButton) {
throw new Error(`No registered console found at index [${atIndex}]`);
}
userEvent.click(runningConsoleShowButton);
await waitFor(() => {
expect(
renderResult.getByTestId('consolePopupWrapper').classList.contains('is-hidden')
).toBe(false);
expect(renderResult.getByTestId('consolePageOverlay'));
});
},
hideOpenedConsole: async () => {
const hideConsoleButton = renderResult.queryByTestId('consolePopupHideButton');
userEvent.click(renderResult.getByTestId('consolePageOverlay-doneButton'));
if (!hideConsoleButton) {
return;
}
userEvent.click(hideConsoleButton);
await waitFor(() => {
expect(
renderResult.getByTestId('consolePopupWrapper').classList.contains('is-hidden')
).toBe(true);
expect(renderResult.queryByTestId('consolePageOverlay')).toBeNull();
});
},
};
@ -102,7 +93,7 @@ const RunningConsole = memo<{ registeredConsole: RegisteredConsoleClient }>(
<div data-test-subj="runningConsole">
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow data-test-subj="runningConsoleTitle">
{registeredConsole.title}
{registeredConsole.id}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton

View file

@ -5,24 +5,47 @@
* 2.0.
*/
import type { ReactNode } from 'react';
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { ComponentType } from 'react';
import { ConsoleProps } from '../../types';
export interface ConsoleRegistrationInterface<Meta extends object = Record<string, unknown>> {
export interface ConsoleRegistrationInterface<TMeta extends object = any> {
id: string;
/** The title for the console popup */
title: ReactNode;
consoleProps: ConsoleProps;
onBeforeTerminate?: () => void;
/**
* Any additional metadata about the console. Helpful for when consuming Registered consoles
* (ex. could hold the details data for the Host that the console is opened against)
*/
meta?: Meta;
meta?: TMeta;
/** An optional component used to render the Overlay page title where the console will be displayed */
PageTitleComponent?: ComponentType<ManagedConsoleExtensionComponentProps<TMeta>>;
/**
* An optional component that will be rendered in the Responder page overlay, above the Console area
*/
PageBodyComponent?: ComponentType<ManagedConsoleExtensionComponentProps<TMeta>>;
/**
* An array of Action components (likely buttons) that will be rendered into the Responder page
* overlay header (next to the `Done` button).
*
* NOTE: this is an Array of `Component`'s - not `JSX`. These will be initialized/rendered when
* the Responder page overlay is shown.
*/
ActionComponents?: Array<ComponentType<ManagedConsoleExtensionComponentProps<TMeta>>>;
}
export interface RegisteredConsoleClient<Meta extends object = Record<string, unknown>>
extends Pick<ConsoleRegistrationInterface<Meta>, 'id' | 'title' | 'meta'> {
/**
* The Props that are provided to the component constructors provided in `ConsoleRegistrationInterface`
*/
export interface ManagedConsoleExtensionComponentProps<TMeta extends object = any> {
meta: { [key in keyof TMeta]: TMeta[key] };
}
export interface RegisteredConsoleClient<TMeta extends object = any>
extends Pick<ConsoleRegistrationInterface<TMeta>, 'id' | 'meta'> {
show(): void;
hide(): void;
@ -34,7 +57,9 @@ export interface RegisteredConsoleClient<Meta extends object = Record<string, un
export interface ConsoleManagerClient {
/** Registers a new console */
register(console: ConsoleRegistrationInterface): Readonly<RegisteredConsoleClient>;
register<TMeta extends object = any>(
console: ConsoleRegistrationInterface<TMeta>
): Readonly<RegisteredConsoleClient>;
/** Opens console in a dialog */
show(id: string): void;

View file

@ -5,9 +5,18 @@
* 2.0.
*/
import React, { useReducer, memo, createContext, PropsWithChildren, useContext } from 'react';
import React, {
useReducer,
memo,
createContext,
PropsWithChildren,
useContext,
useEffect,
useCallback,
} from 'react';
import { useWithManagedConsoleState } from '../console_manager/console_manager';
import { InitialStateInterface, initiateState, stateDataReducer } from './state_reducer';
import { ConsoleStore } from './types';
import type { ConsoleDataState, ConsoleStore } from './types';
const ConsoleStateContext = createContext<null | ConsoleStore>(null);
@ -17,13 +26,31 @@ type ConsoleStateProviderProps = PropsWithChildren<{}> & InitialStateInterface;
* A Console wide data store for internal state management between inner components
*/
export const ConsoleStateProvider = memo<ConsoleStateProviderProps>(
({ commands, scrollToBottom, HelpComponent, dataTestSubj, children }) => {
({ commands, scrollToBottom, HelpComponent, dataTestSubj, managedKey, children }) => {
const [getConsoleState, storeConsoleState] = useWithManagedConsoleState(managedKey);
const stateInitializer = useCallback(
(stateInit: InitialStateInterface): ConsoleDataState => {
return initiateState(stateInit, getConsoleState ? getConsoleState() : undefined);
},
[getConsoleState]
);
const [state, dispatch] = useReducer(
stateDataReducer,
{ commands, scrollToBottom, HelpComponent, dataTestSubj },
initiateState
stateInitializer
);
// Anytime `state` changes AND the console is under ConsoleManager's control, then
// store the console's state to ConsoleManager. This is what enables a console to be
// closed/re-opened while maintaining the console's content
useEffect(() => {
if (storeConsoleState) {
storeConsoleState(state);
}
}, [state, storeConsoleState]);
return (
<ConsoleStateContext.Provider value={{ state, dispatch }}>
{children}

View file

@ -13,23 +13,41 @@ import { getBuiltinCommands } from '../../service/builtin_commands';
export type InitialStateInterface = Pick<
ConsoleDataState,
'commands' | 'scrollToBottom' | 'dataTestSubj' | 'HelpComponent'
'commands' | 'scrollToBottom' | 'dataTestSubj' | 'HelpComponent' | 'managedKey'
>;
export const initiateState = ({
commands,
scrollToBottom,
dataTestSubj,
HelpComponent,
}: InitialStateInterface): ConsoleDataState => {
return {
commands: getBuiltinCommands().concat(commands),
export const initiateState = (
{
commands: commandList,
scrollToBottom,
dataTestSubj,
HelpComponent,
managedKey,
}: InitialStateInterface,
managedConsolePriorState?: ConsoleDataState
): ConsoleDataState => {
const commands = getBuiltinCommands().concat(commandList);
const state = managedConsolePriorState ?? {
commands,
scrollToBottom,
HelpComponent,
dataTestSubj,
managedKey,
commandHistory: [],
sidePanel: { show: null },
};
if (managedConsolePriorState) {
Object.assign(state, {
commands,
scrollToBottom,
HelpComponent,
dataTestSubj,
managedKey,
});
}
return state;
};
export const stateDataReducer: ConsoleStoreReducer = (state, action) => {

View file

@ -23,14 +23,17 @@ export interface ConsoleDataState {
*/
commandHistory: CommandHistoryItem[];
sidePanel: {
show: null | 'help'; // will have other values in the future
};
/** Component defined on input to the Console that will handle the `help` command */
HelpComponent?: CommandExecutionComponent;
dataTestSubj?: string;
sidePanel: {
show: null | 'help'; // will have other values in the future
};
/** The key for the console when it is under ConsoleManager control */
managedKey?: symbol;
}
export interface CommandHistoryItem {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { memo, useCallback, useRef } from 'react';
import React, { memo, useCallback, useEffect, useRef } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import styled from 'styled-components';
import { ConsoleHeader } from './components/console_header';
@ -104,61 +104,61 @@ export const Console = memo<ConsoleProps>(
}
}, []);
// When the console is shown, set focus to it so that user can just start typing
useEffect(() => {
if (!managedConsole || managedConsole.isOpen) {
setTimeout(handleConsoleClick, 2);
}
}, [handleConsoleClick, managedConsole]);
return (
<ConsoleStateProvider
commands={commands}
scrollToBottom={scrollToBottom}
managedKey={managedKey}
HelpComponent={HelpComponent}
dataTestSubj={commonProps['data-test-subj']}
>
{/*
If this is a managed console, then we only show its content if it is open.
The state provider, however, continues to be rendered so that as updates to pending
commands are received, those will still make it to the console's state and be
shown when the console is eventually opened again.
*/}
{!managedConsole || managedConsole.isOpen ? (
<ConsoleWindow onClick={handleConsoleClick} {...commonProps}>
<EuiFlexGroup
direction="column"
className="layout"
gutterSize="none"
responsive={false}
data-test-subj={getTestId('mainPanel')}
>
<EuiFlexItem grow={false} className="layout-container layout-header">
<ConsoleHeader TitleComponent={TitleComponent} />
</EuiFlexItem>
<ConsoleWindow onClick={handleConsoleClick} {...commonProps}>
<EuiFlexGroup
direction="column"
className="layout"
gutterSize="none"
responsive={false}
data-test-subj={getTestId('mainPanel')}
>
<EuiFlexItem grow={false} className="layout-container layout-header">
<ConsoleHeader TitleComponent={TitleComponent} />
</EuiFlexItem>
<EuiFlexItem grow className="layout-hideOverflow">
<EuiFlexGroup gutterSize="none" responsive={false} className="layout-hideOverflow">
<EuiFlexItem className="eui-fullHeight layout-hideOverflow">
<EuiFlexGroup
direction="column"
gutterSize="none"
responsive={false}
className="layout-hideOverflow"
>
<EuiFlexItem grow className="layout-historyOutput">
<div
className="layout-container layout-historyViewport eui-scrollBar eui-yScroll"
ref={scrollingViewport}
>
<HistoryOutput />
</div>
</EuiFlexItem>
<EuiFlexItem grow={false} className="layout-container layout-commandInput">
<CommandInput prompt={prompt} focusRef={inputFocusRef} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow className="layout-hideOverflow">
<EuiFlexGroup gutterSize="none" responsive={false} className="layout-hideOverflow">
<EuiFlexItem className="eui-fullHeight layout-hideOverflow">
<EuiFlexGroup
direction="column"
gutterSize="none"
responsive={false}
className="layout-hideOverflow"
>
<EuiFlexItem grow className="layout-historyOutput">
<div
className="layout-container layout-historyViewport eui-scrollBar eui-yScroll"
ref={scrollingViewport}
>
<HistoryOutput />
</div>
</EuiFlexItem>
<EuiFlexItem grow={false} className="layout-container layout-commandInput">
<CommandInput prompt={prompt} focusRef={inputFocusRef} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
{<SidePanelFlexItem />}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</ConsoleWindow>
) : null}
{<SidePanelFlexItem />}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</ConsoleWindow>
</ConsoleStateProvider>
);
}

View file

@ -10,6 +10,7 @@ export { ConsoleManager, useConsoleManager } from './components/console_manager'
export type { CommandDefinition, Command, ConsoleProps } from './types';
export type {
ConsoleRegistrationInterface,
ManagedConsoleExtensionComponentProps,
RegisteredConsoleClient,
ConsoleManagerClient,
} from './components/console_manager/types';

View file

@ -0,0 +1,35 @@
/*
* 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, useState } from 'react';
import { EuiButton, EuiFlyout } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { EndpointResponderExtensionComponentProps } from './types';
export const ActionLogButton = memo<EndpointResponderExtensionComponentProps>((props) => {
const [showActionLogFlyout, setShowActionLogFlyout] = useState<boolean>(false);
const toggleActionLog = useCallback(() => {
setShowActionLogFlyout((prevState) => {
return !prevState;
});
}, []);
return (
<>
<EuiButton onClick={toggleActionLog} disabled={showActionLogFlyout} iconType="list">
<FormattedMessage
id="xpack.securitySolution.actionLogButton.label"
defaultMessage="Action log"
/>
</EuiButton>
{showActionLogFlyout && (
<EuiFlyout onClose={toggleActionLog}>{'TODO: flyout content will go here'}</EuiFlyout>
)}
</>
);
});
ActionLogButton.displayName = 'ActionLogButton';

View file

@ -6,3 +6,4 @@
*/
export { getEndpointResponseActionsConsoleCommands } from './endpoint_response_actions_console_commands';
export { ActionLogButton } from './action_log_button';

View file

@ -154,10 +154,11 @@ describe('When using isolate action from response actions console', () => {
expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledTimes(2);
await consoleManagerMockAccess.hideOpenedConsole();
await consoleManagerMockAccess.openRunningConsole();
expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledTimes(3);
await waitFor(() => {
expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledTimes(3);
});
});
it('should display completion output if done (no additional API calls)', async () => {
@ -165,7 +166,6 @@ describe('When using isolate action from response actions console', () => {
expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledTimes(1);
await consoleManagerMockAccess.hideOpenedConsole();
await consoleManagerMockAccess.openRunningConsole();
expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledTimes(1);

View file

@ -155,10 +155,11 @@ describe('When using the release action from response actions console', () => {
expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledTimes(2);
await consoleManagerMockAccess.hideOpenedConsole();
await consoleManagerMockAccess.openRunningConsole();
expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledTimes(3);
await waitFor(() => {
expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledTimes(3);
});
});
it('should display completion output if done (no additional API calls)', async () => {
@ -166,7 +167,6 @@ describe('When using the release action from response actions console', () => {
expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledTimes(1);
await consoleManagerMockAccess.hideOpenedConsole();
await consoleManagerMockAccess.openRunningConsole();
expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledTimes(1);

View file

@ -144,6 +144,15 @@ export const EndpointStatusActionResult = memo<
{...pendingIsolationActions}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s">
<FormattedMessage
id="xpack.securitySolution.endpointResponseActions.status.version"
defaultMessage="Version"
/>
</EuiText>
<EuiText>{endpointDetails.metadata.agent.version}</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s">
<FormattedMessage

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ManagedConsoleExtensionComponentProps } from '../console';
import { HostMetadata } from '../../../../common/endpoint/types';
export interface EndpointCommandDefinitionMeta {
endpointId: string;
}
export type EndpointResponderExtensionComponentProps = ManagedConsoleExtensionComponentProps<{
endpoint: HostMetadata;
}>;

View file

@ -5,4 +5,5 @@
* 2.0.
*/
export { useResponseActionsConsoleActionItem } from './use_response_actions_console_action_item';
export { PageOverlay } from './page_overlay';
export type { PageOverlayProps } from './page_overlay';

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
describe('When using PageOverlay component', () => {
it.todo('should display the overlay using minimal props');
it.todo('should call `onHide` callback when done button is clicked');
it.todo('should set classname on `<body>` when visible');
it.todo('should prevent browser window scrolling when `lockDocumentBody` is `true`');
it.todo('should remove all classnames from `<body>` when hidden/unmounted');
it.todo(
'should move the overlay to be the last child of `<body>` if `appendAsBodyLastNode` prop is `true`'
);
it.todo('should call `onHide` when `hideOnUrlPathnameChange` is `true` and url changes');
it.todo('should NOT call `onHide` when `hideOnUrlPathnameChange` is `false` and url changes');
it.todo('should add padding class names when `paddingSize` prop is defined');
});

View file

@ -0,0 +1,298 @@
/*
* 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,
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
CSSProperties,
} from 'react';
import styled, { createGlobalStyle } from 'styled-components';
import { EuiFocusTrap, EuiPortal } from '@elastic/eui';
import classnames from 'classnames';
import { useLocation } from 'react-router-dom';
import { EuiPortalProps } from '@elastic/eui/src/components/portal/portal';
import { EuiTheme } from '@kbn/kibana-react-plugin/common';
import { TIMELINE_OVERRIDES_CSS_STYLESHEET } from '../../../common/components/page';
import {
SELECTOR_TIMELINE_IS_VISIBLE_CSS_CLASS_NAME,
TIMELINE_EUI_THEME_ZINDEX_LEVEL,
} from '../../../timelines/components/timeline/styles';
import { useIsMounted } from '../../hooks/use_is_mounted';
const OverlayRootContainer = styled.div`
border: none;
display: block;
position: fixed;
overflow: hidden;
top: calc((${({ theme: { eui } }) => eui.euiHeaderHeightCompensation} * 2));
bottom: 0;
right: 0;
height: calc(100% - ${({ theme: { eui } }) => eui.euiHeaderHeightCompensation} * 2);
width: 100%;
z-index: ${({ theme: { eui } }) => eui.euiZFlyout};
background-color: ${({ theme: { eui } }) => eui.euiColorEmptyShade};
&.scrolling {
overflow: auto;
}
&.hidden {
display: none;
}
&.padding-xs {
padding: ${({ theme: { eui } }) => eui.paddingSizes.xs};
}
&.padding-s {
padding: ${({ theme: { eui } }) => eui.paddingSizes.s};
}
&.padding-m {
padding: ${({ theme: { eui } }) => eui.paddingSizes.m};
}
&.padding-l {
padding: ${({ theme: { eui } }) => eui.paddingSizes.l};
}
&.padding-xl {
padding: ${({ theme: { eui } }) => eui.paddingSizes.xl};
}
.fullHeight {
height: 100%;
}
`;
const PAGE_OVERLAY_CSS_CLASSNAME = 'securitySolution-pageOverlay';
const PAGE_OVERLAY_DOCUMENT_BODY_IS_VISIBLE_CLASSNAME = `${PAGE_OVERLAY_CSS_CLASSNAME}-isVisible`;
const PAGE_OVERLAY_DOCUMENT_BODY_LOCK_CLASSNAME = `${PAGE_OVERLAY_CSS_CLASSNAME}-lock`;
const PageOverlayGlobalStyles = createGlobalStyle<{ theme: EuiTheme }>`
body.${PAGE_OVERLAY_DOCUMENT_BODY_LOCK_CLASSNAME} {
overflow: hidden;
}
//-------------------------------------------------------------------------------------------
// TIMELINE SPECIFIC STYLES
// The timeline overlay uses a custom z-index, which causes issues with any other content that
// is normally appended to the 'document.body' directly (like popups, masks, flyouts, etc).
// The styles below will be applied anytime the timeline is opened/visible and attempts to
// mitigate the issues around z-index so that content that is shown after the PageOverlay is
// opened is displayed properly.
//-------------------------------------------------------------------------------------------
body.${SELECTOR_TIMELINE_IS_VISIBLE_CSS_CLASS_NAME}.${PAGE_OVERLAY_DOCUMENT_BODY_IS_VISIBLE_CLASSNAME} {
.${PAGE_OVERLAY_CSS_CLASSNAME},
.euiOverlayMask,
.euiFlyout {
z-index: ${({ theme: { eui } }) => eui[TIMELINE_EUI_THEME_ZINDEX_LEVEL]};
}
// Other Timeline overrides from AppGlobalStyle:
// x-pack/plugins/security_solution/public/common/components/page/index.tsx
${TIMELINE_OVERRIDES_CSS_STYLESHEET}
}
`;
const setDocumentBodyOverlayIsVisible = () => {
document.body.classList.add(PAGE_OVERLAY_DOCUMENT_BODY_IS_VISIBLE_CLASSNAME);
};
const unSetDocumentBodyOverlayIsVisible = () => {
document.body.classList.remove(PAGE_OVERLAY_DOCUMENT_BODY_IS_VISIBLE_CLASSNAME);
};
const setDocumentBodyLock = () => {
document.body.classList.add(PAGE_OVERLAY_DOCUMENT_BODY_LOCK_CLASSNAME);
};
const unSetDocumentBodyLock = () => {
document.body.classList.remove(PAGE_OVERLAY_DOCUMENT_BODY_LOCK_CLASSNAME);
};
export interface PageOverlayProps {
children: ReactNode;
/**
* Callback for when the user leaves the overlay
*/
onHide: () => void;
/** If the overlay should be hidden. NOTE: it will remain rendered/mounted, but `display: none` */
isHidden?: boolean;
/**
* Setting this to `true` (defualt) will enable scrolling inside of the overlay
*/
enableScrolling?: boolean;
/**
* When set to `true` (default), the browser's scroll bar will be "locked" (`overflow: hidden`),
* so that the only scroll bar visible (possibly) is the one inside of the overlay
*/
lockDocumentBody?: boolean;
/**
* If `true` (default), then the page's URL `pathname` will be tracked when the overlay is shown, and if
* the pathname changes, the `onHide()` prop will be called.
*/
hideOnUrlPathnameChange?: boolean;
/**
* Optional padding size around the overlay
*/
paddingSize?: 'xs' | 's' | 'm' | 'l' | 'xl';
/**
* If set to `true` (default), whenever the page overlay is displayed (either mounted or when
* `isHidden` goes form `false` to `true`), it will be moved so that it is the last child of
* inside of the `<body>`. This happens only when the page overlay is displayed
* (ie. it does NOT attempt to track nodes added/removed from `<body>` in order to ensure
* it is always the last one).
*/
appendAsBodyLastNode?: boolean;
/**
* Explicitly set the overlay's z-index. Use with caution.
*/
zIndex?: CSSProperties['zIndex'];
'data-test-subj'?: string;
}
/**
* A generic component for taking over the entire Kibana UI main content area (everything below the
* top header that includes the breadcrumbs).
*/
export const PageOverlay = memo<PageOverlayProps>(
({
children,
onHide,
isHidden = false,
enableScrolling = true,
hideOnUrlPathnameChange = true,
lockDocumentBody = true,
appendAsBodyLastNode = true,
paddingSize,
zIndex,
'data-test-subj': dataTestSubj,
}) => {
const { pathname } = useLocation();
const isMounted = useIsMounted();
const [openedOnPathName, setOpenedOnPathName] = useState<null | string>(null);
const portalEleRef = useRef<Node>();
const setPortalEleRef: EuiPortalProps['portalRef'] = useCallback((node) => {
portalEleRef.current = node;
}, []);
const containerCssOverrides = useMemo<CSSProperties>(() => {
const css: CSSProperties = {};
if (zIndex) {
css.zIndex = zIndex;
}
return css;
}, [zIndex]);
const containerClassName = useMemo(() => {
return classnames({
[PAGE_OVERLAY_CSS_CLASSNAME]: true,
scrolling: enableScrolling,
hidden: isHidden,
'eui-scrollBar': enableScrolling,
'padding-xs': paddingSize === 'xs',
'padding-s': paddingSize === 's',
'padding-m': paddingSize === 'm',
'padding-l': paddingSize === 'l',
'padding-xl': paddingSize === 'xl',
});
}, [enableScrolling, isHidden, paddingSize]);
// Capture the URL `pathname` that the overlay was opened for
useEffect(() => {
if (isMounted) {
setOpenedOnPathName((prevState) => {
if (isHidden) {
return null;
} else {
// capture pathname if not yet set (first show)
if (!prevState) {
// append the portal to the end of `<body>`?
if (appendAsBodyLastNode && portalEleRef.current) {
document.body.appendChild(portalEleRef.current);
}
return pathname;
}
}
return prevState;
});
}
}, [appendAsBodyLastNode, isHidden, isMounted, pathname]);
// If `hideOnUrlPathNameChange` is true, then determine if the pathname changed and if so, call `onHide()`
useEffect(() => {
if (
isMounted &&
onHide &&
hideOnUrlPathnameChange &&
!isHidden &&
openedOnPathName &&
openedOnPathName !== pathname
) {
onHide();
}
}, [hideOnUrlPathnameChange, isHidden, isMounted, onHide, openedOnPathName, pathname]);
// Handle adding class names to the `document.body` DOM element
useEffect(() => {
if (isMounted) {
if (isHidden) {
unSetDocumentBodyOverlayIsVisible();
unSetDocumentBodyLock();
} else {
setDocumentBodyOverlayIsVisible();
if (lockDocumentBody) {
setDocumentBodyLock();
}
}
}
return () => {
unSetDocumentBodyLock();
unSetDocumentBodyOverlayIsVisible();
};
}, [isHidden, isMounted, lockDocumentBody]);
return (
<EuiPortal portalRef={setPortalEleRef}>
<OverlayRootContainer
data-test-subj={dataTestSubj}
className={containerClassName}
style={containerCssOverrides}
>
<EuiFocusTrap data-test-subj="trap-focus" className="fullHeight">
{children}
</EuiFocusTrap>
</OverlayRootContainer>
<PageOverlayGlobalStyles />
</EuiPortal>
);
}
);
PageOverlay.displayName = 'PageOverlay';

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { useMutation, UseMutationOptions } from 'react-query';
import { useMutation, UseMutationOptions, UseMutationResult } from 'react-query';
import { HttpFetchError } from '@kbn/core/public';
import { isolateHost } from '../../../common/lib/endpoint_isolation';
import { HostIsolationRequestBody, HostIsolationResponse } from '../../../../common/endpoint/types';
@ -20,7 +20,7 @@ export const useSendIsolateEndpointRequest = (
HttpFetchError,
HostIsolationRequestBody
>
) => {
): UseMutationResult<HostIsolationResponse, HttpFetchError, HostIsolationRequestBody> => {
return useMutation<HostIsolationResponse, HttpFetchError, HostIsolationRequestBody>(
(isolateData: HostIsolationRequestBody) => {
return isolateHost(isolateData);

View file

@ -6,13 +6,21 @@
*/
import React, { useCallback } from 'react';
import { getEndpointResponseActionsConsoleCommands } from '../../components/endpoint_console';
import { i18n } from '@kbn/i18n';
import {
ActionLogButton,
getEndpointResponseActionsConsoleCommands,
} from '../../components/endpoint_responder';
import { useConsoleManager } from '../../components/console';
import type { HostMetadata } from '../../../../common/endpoint/types';
type ShowEndpointResponseActionsConsole = (endpointMetadata: HostMetadata) => void;
export const useShowEndpointResponseActionsConsole = (): ShowEndpointResponseActionsConsole => {
const RESPONDER_PAGE_TITLE = i18n.translate('xpack.securitySolution.responder_overlay.pageTitle', {
defaultMessage: 'Responder',
});
export const useWithShowEndpointResponder = (): ShowEndpointResponseActionsConsole => {
const consoleManager = useConsoleManager();
return useCallback(
@ -26,13 +34,17 @@ export const useShowEndpointResponseActionsConsole = (): ShowEndpointResponseAct
consoleManager
.register({
id: endpointAgentId,
title: `${endpointMetadata.host.name} - Endpoint v${endpointMetadata.agent.version}`,
meta: {
endpoint: endpointMetadata,
},
consoleProps: {
commands: getEndpointResponseActionsConsoleCommands(endpointAgentId),
'data-test-subj': 'endpointResponseActionsConsole',
prompt: `endpoint-${endpointMetadata.agent.version}`,
TitleComponent: () => <>{endpointMetadata.host.name}</>,
},
PageTitleComponent: () => <>{RESPONDER_PAGE_TITLE}</>,
ActionComponents: [ActionLogButton],
})
.show();
}

View file

@ -6,4 +6,4 @@
*/
export { useGetEndpointDetails } from './endpoint/use_get_endpoint_details';
export { useShowEndpointResponseActionsConsole } from './endpoint/use_show_endpoint_response_actions_console';
export { useWithShowEndpointResponder } from './endpoint/use_with_show_endpoint_responder';

View file

@ -31,7 +31,6 @@ export const EndpointDetailsFlyout = memo(() => {
return (
<EuiFlyout
onClose={handleFlyoutClose}
style={{ zIndex: 4001 }}
data-test-subj="endpointDetailsFlyout"
size="m"
paddingSize="l"

View file

@ -8,7 +8,7 @@
import React, { useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { pagePathGetters } from '@kbn/fleet-plugin/public';
import { useShowEndpointResponseActionsConsole } from '../../../../hooks';
import { useWithShowEndpointResponder } from '../../../../hooks';
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
import { APP_UI_ID } from '../../../../../../common/constants';
import { getEndpointDetailsPath } from '../../../../common/routing';
@ -32,7 +32,7 @@ export const useEndpointActionItems = (
const { getAppUrl } = useAppUrl();
const fleetAgentPolicies = useEndpointSelector(agentPolicies);
const allCurrentUrlParams = useEndpointSelector(uiQueryParams);
const showEndpointResponseActionsConsole = useShowEndpointResponseActionsConsole();
const showEndpointResponseActionsConsole = useWithShowEndpointResponder();
const isResponseActionsConsoleEnabled = useIsExperimentalFeatureEnabled(
'responseActionsConsoleEnabled'
);

View file

@ -11,7 +11,11 @@ import React from 'react';
import { AGENT_API_ROUTES, PACKAGE_POLICY_API_ROOT } from '@kbn/fleet-plugin/common';
import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data';
import { useUserPrivileges } from '../../../../common/components/user_privileges';
import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint';
import {
AppContextTestRender,
createAppRootMockRenderer,
resetReactDomCreatePortalMock,
} from '../../../../common/mock/endpoint';
import { getEndpointListPath, getPoliciesPath, getPolicyDetailPath } from '../../../common/routing';
import { policyListApiPathHandlers } from '../store/test_mock_utils';
import { PolicyDetails } from './policy_details';
@ -39,6 +43,8 @@ describe('Policy Details', () => {
let policyPackagePolicy: ReturnType<typeof generator.generatePolicyPackagePolicy>;
let policyView: ReturnType<typeof render>;
beforeAll(() => resetReactDomCreatePortalMock());
beforeEach(() => {
const appContextMockRenderer = createAppRootMockRenderer();
const AppWrapper = appContextMockRenderer.AppWrapper;

View file

@ -14,6 +14,7 @@ import { EndpointDocGenerator } from '../../../../../../../common/endpoint/gener
import {
AppContextTestRender,
createAppRootMockRenderer,
resetReactDomCreatePortalMock,
} from '../../../../../../common/mock/endpoint';
import { getPolicyDetailPath, getEndpointListPath } from '../../../../../common/routing';
import { policyListApiPathHandlers } from '../../../store/test_mock_utils';
@ -36,6 +37,8 @@ describe('Policy Form Layout', () => {
let policyPackagePolicy: ReturnType<typeof generator.generatePolicyPackagePolicy>;
let policyFormLayoutView: ReturnType<typeof render>;
beforeAll(() => resetReactDomCreatePortalMock());
beforeEach(() => {
const appContextMockRenderer = createAppRootMockRenderer();
const AppWrapper = appContextMockRenderer.AppWrapper;

View file

@ -6,10 +6,14 @@
*/
import { EuiFlyout, EuiFlyoutProps } from '@elastic/eui';
import React, { useCallback } from 'react';
import React, { useCallback, useEffect } from 'react';
import styled, { createGlobalStyle } from 'styled-components';
import { useDispatch } from 'react-redux';
import {
SELECTOR_TIMELINE_IS_VISIBLE_CSS_CLASS_NAME,
TIMELINE_EUI_THEME_ZINDEX_LEVEL,
} from '../../timeline/styles';
import { StatefulTimeline } from '../../timeline';
import { TimelineId } from '../../../../../common/types/timeline';
import * as i18n from './translations';
@ -26,7 +30,7 @@ interface FlyoutPaneComponentProps {
const StyledEuiFlyout = styled(EuiFlyout)<EuiFlyoutProps>`
animation: none;
min-width: 150px;
z-index: ${({ theme }) => theme.eui.euiZLevel4};
z-index: ${({ theme }) => theme.eui[TIMELINE_EUI_THEME_ZINDEX_LEVEL]};
`;
// SIDE EFFECT: the following creates a global class selector
@ -50,6 +54,14 @@ const FlyoutPaneComponent: React.FC<FlyoutPaneComponentProps> = ({
focusActiveTimelineButton();
}, [dispatch, timelineId]);
useEffect(() => {
if (visible) {
document.body.classList.add(SELECTOR_TIMELINE_IS_VISIBLE_CSS_CLASS_NAME);
} else {
document.body.classList.remove(SELECTOR_TIMELINE_IS_VISIBLE_CSS_CLASS_NAME);
}
}, [visible]);
return (
<div data-test-subj="flyout-pane" style={{ display: visible ? 'block' : 'none' }}>
<StyledEuiFlyout

View file

@ -74,7 +74,11 @@ jest.mock(
jest.mock('../../../../../cases/components/use_insert_timeline');
jest.mock('../../../../../common/utils/endpoint_alert_check', () => {
const realEndpointAlertCheckUtils = jest.requireActual(
'../../../../../common/utils/endpoint_alert_check'
);
return {
isTimelineEventItemAnAlert: realEndpointAlertCheckUtils.isTimelineEventItemAnAlert,
isAlertFromEndpointAlert: jest.fn().mockReturnValue(true),
isAlertFromEndpointEvent: jest.fn().mockReturnValue(true),
};

View file

@ -5,6 +5,33 @@ exports[`Expandable Host Component ExpandableHostDetails: rendering it should re
color: #535966;
}
.c2 dt {
font-size: 12px !important;
}
.c2 dd {
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
}
.c2 dd > div {
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
}
.c1 {
position: relative;
}
.c1 .euiButtonIcon {
position: absolute;
right: 12px;
top: 6px;
z-index: 2;
}
.c0 {
width: 100%;
display: -webkit-box;
@ -33,33 +60,6 @@ exports[`Expandable Host Component ExpandableHostDetails: rendering it should re
opacity: 1;
}
.c2 dt {
font-size: 12px !important;
}
.c2 dd {
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
}
.c2 dd > div {
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
}
.c1 {
position: relative;
}
.c1 .euiButtonIcon {
position: absolute;
right: 12px;
top: 6px;
z-index: 2;
}
.c4 {
padding: 16px;
background: rgba(250,251,253,0.9);

View file

@ -15,6 +15,16 @@ import { TimelineEventsType } from '../../../../common/types/timeline';
import { ACTIONS_COLUMN_ARIA_COL_INDEX } from './helpers';
import { EVENTS_TABLE_ARIA_LABEL } from './translations';
/**
* The EUI theme's z-index property that is used by the timeline overlay
*/
export const TIMELINE_EUI_THEME_ZINDEX_LEVEL = 'euiZLevel4';
/**
* The css classname added to the `document.body` whenever the timeline is visible on the page
*/
export const SELECTOR_TIMELINE_IS_VISIBLE_CSS_CLASS_NAME = 'securitySolutionTimeline-isVisible';
/**
* TIMELINE BODY
*/