mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
b0b36d06b0
commit
d1fe508f42
47 changed files with 1119 additions and 660 deletions
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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`};
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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;
|
||||
|
||||
/**
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -5,6 +5,4 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export interface EndpointCommandDefinitionMeta {
|
||||
endpointId: string;
|
||||
}
|
||||
export { useResponderActionItem } from './use_responder_action_item';
|
|
@ -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';
|
|
@ -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,
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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';
|
|
@ -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';
|
|
@ -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';
|
|
@ -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';
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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]);
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
|
@ -6,3 +6,4 @@
|
|||
*/
|
||||
|
||||
export { getEndpointResponseActionsConsoleCommands } from './endpoint_response_actions_console_commands';
|
||||
export { ActionLogButton } from './action_log_button';
|
|
@ -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);
|
|
@ -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);
|
|
@ -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
|
|
@ -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;
|
||||
}>;
|
|
@ -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';
|
|
@ -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');
|
||||
});
|
|
@ -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';
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -31,7 +31,6 @@ export const EndpointDetailsFlyout = memo(() => {
|
|||
return (
|
||||
<EuiFlyout
|
||||
onClose={handleFlyoutClose}
|
||||
style={{ zIndex: 4001 }}
|
||||
data-test-subj="endpointDetailsFlyout"
|
||||
size="m"
|
||||
paddingSize="l"
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue