mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Security Solution] - Timeline UI refactor (#168230)
## Summary This PR implements many small refactors in the Timeline UI. I have listed all the changes below which can help you while you are desk testing. ### EQL Bar |Before|After| |--|--| || ### Timeline Title Bar / Bottom Bar Below screenshots show how timeline bottom bar has changed. Things to note: - Favorite button is now just an icon with the title. User can simply click on it to favorite /un-favorite a timeline |Before | After| |---|---| || Below screenshots show how timeline title bar has changed. Things to note : - A new timeline action menu has been added to right to timeline title bar. - All actions such as create a new timeline, a new timeline template. adding timeline to case, etc can be performed from here. |Before|After| |---|---| || - On the left side of the Timeline Header below are the changes. - Timeline Title is not longer a button/link, so timeline cannot be closed by clicking on that. - ⊕ action menu is not longer available and corresponding actions are available in above screenshots. |Before|After| |--|--| || ### Timeline Header Panel Below timeline header panel has been completely removed.  ### Changes on how Data provider works 1. Data provider is by-default hidden in normal timeline but visible in template timeline. 2. Data provider can be toggled by the user on-demand. 3. Data Provider will automatically become visible if user wants to put a data grid column value in data provider and stars dragging it. Below videos shows how that interaction works.c7232596
-40aa-4687-9fcf-e4a707be8a76 ### KPI This PR also changes how KPIs are visible in empty and populated state. |Before|After| |---|---| || KPI bar has been completely removed till this issue resolves: https://github.com/elastic/kibana/issues/171569 ### Query Bar In contrast to current layout of the query bar, DataView picker, Query bar and Date Picker has been brought in the same line. This was done in an effort to make it uniform in looks w.r.t the global query bar. --------------- #### Before  -------------- #### After All the highlighted components are in the same line now + A button to toggle Data Provider ( as explained in Data Porvider/QueryBuilder Section) has also been added.  ### Spacing Uniformity In the existing version of timeline, spacing is different at many places. This PR aims to bring some uniformity to those spacing decisions ( primarily in EQL and Query Tab). The changes are very minor visually, please feel free to find and report any discrepancies. ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
f7fa8469bd
commit
72d2457ee2
108 changed files with 3230 additions and 2143 deletions
|
@ -55,6 +55,8 @@ const FilterBarUI = React.memo(function FilterBarUI(props: Props) {
|
|||
gutterSize="none" // We use `gap` in the styles instead for better truncation of badges
|
||||
alignItems="center"
|
||||
tabIndex={-1}
|
||||
data-test-subj="filter-items-group"
|
||||
className={`filter-items-group ${props.className ?? ''}`}
|
||||
>
|
||||
{props.prepend}
|
||||
<FilterItems
|
||||
|
|
|
@ -22,7 +22,8 @@ const RoundBadge = styled(EuiBadge)`
|
|||
margin: 0 5px 0 5px;
|
||||
padding: 7px 6px 4px 6px;
|
||||
user-select: none;
|
||||
width: 34px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
.euiBadge__content {
|
||||
position: relative;
|
||||
top: -1px;
|
||||
|
|
|
@ -7,14 +7,13 @@
|
|||
|
||||
import { noop, pick } from 'lodash/fp';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import type { DropResult } from '@hello-pangea/dnd';
|
||||
import type { DragStart, DropResult } from '@hello-pangea/dnd';
|
||||
import { DragDropContext } from '@hello-pangea/dnd';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import type { Dispatch } from 'redux';
|
||||
import deepEqual from 'fast-deep-equal';
|
||||
import { IS_DRAGGING_CLASS_NAME } from '@kbn/securitysolution-t-grid';
|
||||
|
||||
import type { BeforeCapture } from './drag_drop_context';
|
||||
import type { BrowserFields } from '../../containers/source';
|
||||
import { dragAndDropSelectors } from '../../store';
|
||||
import { timelineSelectors } from '../../../timelines/store/timeline';
|
||||
|
@ -151,8 +150,9 @@ export const DragDropContextWrapperComponent: React.FC<Props> = ({ browserFields
|
|||
},
|
||||
[activeTimelineDataProviders, browserFields, dataProviders, dispatch, onAddedToTimeline]
|
||||
);
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={onDragEnd} onBeforeCapture={onBeforeCapture} sensors={sensors}>
|
||||
<DragDropContext onBeforeDragStart={onBeforeDragStart} onDragEnd={onDragEnd} sensors={sensors}>
|
||||
{children}
|
||||
</DragDropContext>
|
||||
);
|
||||
|
@ -168,12 +168,12 @@ export const DragDropContextWrapper = React.memo(
|
|||
|
||||
DragDropContextWrapper.displayName = 'DragDropContextWrapper';
|
||||
|
||||
const onBeforeCapture = (before: BeforeCapture) => {
|
||||
if (!draggableIsField(before)) {
|
||||
const onBeforeDragStart = (start: DragStart) => {
|
||||
if (!draggableIsField(start)) {
|
||||
document.body.classList.add(IS_DRAGGING_CLASS_NAME);
|
||||
}
|
||||
|
||||
if (draggableIsField(before)) {
|
||||
if (draggableIsField(start)) {
|
||||
document.body.classList.add(IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -7,16 +7,11 @@
|
|||
|
||||
import { EuiButton, EuiWindowEvent } from '@elastic/eui';
|
||||
import React, { useCallback } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import * as i18n from './translations';
|
||||
|
||||
export const EXIT_FULL_SCREEN_CLASS_NAME = 'exit-full-screen';
|
||||
|
||||
const StyledEuiButton = styled(EuiButton)`
|
||||
margin: ${({ theme }) => theme.eui.euiSizeS};
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
fullScreen: boolean;
|
||||
setFullScreen: (fullScreen: boolean) => void;
|
||||
|
@ -45,16 +40,17 @@ const ExitFullScreenComponent: React.FC<Props> = ({ fullScreen, setFullScreen })
|
|||
return (
|
||||
<>
|
||||
<EuiWindowEvent event="keydown" handler={onKeyDown} />
|
||||
<StyledEuiButton
|
||||
<EuiButton
|
||||
className={EXIT_FULL_SCREEN_CLASS_NAME}
|
||||
data-test-subj="exit-full-screen"
|
||||
fullWidth={false}
|
||||
iconType="fullScreen"
|
||||
fill
|
||||
isDisabled={!fullScreen}
|
||||
onClick={exitFullScreen}
|
||||
>
|
||||
{i18n.EXIT_FULL_SCREEN}
|
||||
</StyledEuiButton>
|
||||
</EuiButton>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -37,7 +37,7 @@ exports[`HeaderSection it renders 1`] = `
|
|||
>
|
||||
<EuiFlexItem>
|
||||
<EuiTitle
|
||||
size="m"
|
||||
size="l"
|
||||
>
|
||||
<h4
|
||||
data-test-subj="header-section-title"
|
||||
|
|
|
@ -108,7 +108,7 @@ const HeaderSectionComponent: React.FC<HeaderSectionProps> = ({
|
|||
stackHeader,
|
||||
subtitle,
|
||||
title,
|
||||
titleSize = 'm',
|
||||
titleSize = 'l',
|
||||
toggleQuery,
|
||||
toggleStatus = true,
|
||||
tooltip,
|
||||
|
@ -173,7 +173,6 @@ const HeaderSectionComponent: React.FC<HeaderSectionProps> = ({
|
|||
<span className="eui-textBreakNormal">{title}</span>
|
||||
{tooltip && (
|
||||
<>
|
||||
{' '}
|
||||
<EuiIconTip
|
||||
color="subdued"
|
||||
title={tooltipTitle}
|
||||
|
|
|
@ -40,7 +40,7 @@ interface InspectButtonProps {
|
|||
onCloseInspect?: () => void;
|
||||
queryId: string;
|
||||
showInspectButton?: boolean;
|
||||
title: string | React.ReactElement | React.ReactNode;
|
||||
title?: string | React.ReactElement | React.ReactNode;
|
||||
}
|
||||
|
||||
const InspectButtonComponent: React.FC<InspectButtonProps> = ({
|
||||
|
@ -80,9 +80,6 @@ const InspectButtonComponent: React.FC<InspectButtonProps> = ({
|
|||
className={BUTTON_CLASS}
|
||||
aria-label={i18n.INSPECT}
|
||||
data-test-subj="inspect-empty-button"
|
||||
color="text"
|
||||
iconSide="left"
|
||||
iconType="inspect"
|
||||
isDisabled={isButtonDisabled}
|
||||
isLoading={loading}
|
||||
onClick={handleClick}
|
||||
|
|
|
@ -167,7 +167,6 @@ export const QueryBar = memo<QueryBarComponentProps>(
|
|||
savedQuery={savedQuery}
|
||||
displayStyle={displayStyle}
|
||||
isDisabled={isDisabled}
|
||||
hideTextBasedRunQueryLabel
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,139 @@
|
|||
/*
|
||||
* 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 from 'react';
|
||||
|
||||
import { Sourcerer } from '.';
|
||||
import { sourcererModel } from '../../store/sourcerer';
|
||||
import {
|
||||
createSecuritySolutionStorageMock,
|
||||
kibanaObservable,
|
||||
mockGlobalState,
|
||||
SUB_PLUGINS_REDUCER,
|
||||
TestProviders,
|
||||
} from '../../mock';
|
||||
import { createStore } from '../../store';
|
||||
import { useSourcererDataView } from '../../containers/sourcerer';
|
||||
import { useSignalHelpers } from '../../containers/sourcerer/use_signal_helpers';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
|
||||
jest.mock('../../containers/sourcerer');
|
||||
jest.mock('../../containers/sourcerer/use_signal_helpers');
|
||||
const mockUseUpdateDataView = jest.fn().mockReturnValue(() => true);
|
||||
jest.mock('./use_update_data_view', () => ({
|
||||
useUpdateDataView: () => mockUseUpdateDataView,
|
||||
}));
|
||||
jest.mock('react-redux', () => {
|
||||
const original = jest.requireActual('react-redux');
|
||||
|
||||
return {
|
||||
...original,
|
||||
useDispatch: () => mockDispatch,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@kbn/kibana-react-plugin/public', () => {
|
||||
const original = jest.requireActual('@kbn/kibana-react-plugin/public');
|
||||
|
||||
return {
|
||||
...original,
|
||||
toMountPoint: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockUpdateUrlParam = jest.fn();
|
||||
jest.mock('../../utils/global_query_string', () => {
|
||||
const original = jest.requireActual('../../utils/global_query_string');
|
||||
|
||||
return {
|
||||
...original,
|
||||
useUpdateUrlParam: () => mockUpdateUrlParam,
|
||||
};
|
||||
});
|
||||
|
||||
let store: ReturnType<typeof createStore>;
|
||||
const sourcererDataView = {
|
||||
indicesExist: true,
|
||||
loading: false,
|
||||
};
|
||||
describe('sourcerer on alerts page or rules details page', () => {
|
||||
const { storage } = createSecuritySolutionStorageMock();
|
||||
store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
|
||||
const testProps = {
|
||||
scope: sourcererModel.SourcererScopeName.detections,
|
||||
};
|
||||
|
||||
const pollForSignalIndexMock = jest.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
(useSignalHelpers as jest.Mock).mockReturnValue({
|
||||
pollForSignalIndex: pollForSignalIndexMock,
|
||||
signalIndexNeedsInit: false,
|
||||
});
|
||||
|
||||
(useSourcererDataView as jest.Mock).mockReturnValue({
|
||||
...sourcererDataView,
|
||||
indicesExist: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer {...testProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('sourcerer-trigger'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('sourcerer-advanced-options-toggle')).toBeVisible();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('sourcerer-advanced-options-toggle'));
|
||||
});
|
||||
|
||||
it('renders an alerts badge in sourcerer button', () => {
|
||||
expect(screen.getByTestId('sourcerer-advanced-options-toggle')).toHaveTextContent(
|
||||
/Advanced options/
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a callout', () => {
|
||||
expect(screen.getByTestId('sourcerer-callout')).toHaveTextContent(
|
||||
'Data view cannot be modified on this page'
|
||||
);
|
||||
});
|
||||
|
||||
it('disable data view selector', () => {
|
||||
expect(screen.getByTestId('sourcerer-select')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('data view selector is default to Security Data View', () => {
|
||||
expect(screen.getByTestId('sourcerer-select')).toHaveTextContent(/security data view/i);
|
||||
});
|
||||
|
||||
it('renders an alert badge in data view selector', () => {
|
||||
expect(screen.getByTestId('security-alerts-option-badge')).toHaveTextContent('Alerts');
|
||||
});
|
||||
|
||||
it('disable index pattern selector', () => {
|
||||
expect(screen.getByTestId('sourcerer-combo-box')).toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
it('shows signal index as index pattern option', () => {
|
||||
expect(screen.getByTestId('euiComboBoxPill')).toHaveTextContent('.siem-signals-spacename');
|
||||
});
|
||||
|
||||
it('does not render reset button', () => {
|
||||
expect(screen.queryByTestId('sourcerer-reset')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('does not render save button', () => {
|
||||
expect(screen.queryByTestId('sourcerer-save')).toBeFalsy();
|
||||
});
|
||||
});
|
|
@ -10,6 +10,7 @@ import type { EuiSuperSelectOption, EuiFormRowProps } from '@elastic/eui';
|
|||
import { EuiIcon, EuiBadge, EuiButtonEmpty, EuiFormRow } from '@elastic/eui';
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
import { euiThemeVars } from '@kbn/ui-theme';
|
||||
import type { sourcererModel } from '../../store/sourcerer';
|
||||
|
||||
import * as i18n from './translations';
|
||||
|
@ -23,7 +24,7 @@ export const StyledFormRow = styled(EuiFormRow)`
|
|||
max-width: none;
|
||||
`;
|
||||
|
||||
export const StyledButton = styled(EuiButtonEmpty)`
|
||||
export const StyledButtonEmpty = styled(EuiButtonEmpty)`
|
||||
&:enabled:focus,
|
||||
&:focus {
|
||||
background-color: transparent;
|
||||
|
@ -43,7 +44,7 @@ export const PopoverContent = styled.div`
|
|||
`;
|
||||
|
||||
export const StyledBadge = styled(EuiBadge)`
|
||||
margin-left: 8px;
|
||||
margin-left: ${euiThemeVars.euiSizeXS};
|
||||
&,
|
||||
.euiBadge__text {
|
||||
cursor: pointer;
|
||||
|
|
|
@ -8,9 +8,8 @@
|
|||
import React from 'react';
|
||||
import type { ReactWrapper } from 'enzyme';
|
||||
import { mount } from 'enzyme';
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
import { initialSourcererState, SourcererScopeName } from '../../store/sourcerer/model';
|
||||
import { SourcererScopeName } from '../../store/sourcerer/model';
|
||||
import { Sourcerer } from '.';
|
||||
import { sourcererActions, sourcererModel } from '../../store/sourcerer';
|
||||
import {
|
||||
|
@ -22,11 +21,9 @@ import {
|
|||
} from '../../mock';
|
||||
import { createStore } from '../../store';
|
||||
import type { EuiSuperSelectOption } from '@elastic/eui/src/components/form/super_select/super_select_control';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { fireEvent, waitFor, render } from '@testing-library/react';
|
||||
import { useSourcererDataView } from '../../containers/sourcerer';
|
||||
import { useSignalHelpers } from '../../containers/sourcerer/use_signal_helpers';
|
||||
import { TimelineId } from '../../../../common/types/timeline';
|
||||
import { TimelineType } from '../../../../common/api/timeline';
|
||||
import { DEFAULT_INDEX_PATTERN } from '../../../../common/constants';
|
||||
import { sortWithExcludesAtEnd } from '../../../../common/utils/sourcerer';
|
||||
|
||||
|
@ -93,6 +90,7 @@ const sourcererDataView = {
|
|||
describe('Sourcerer component', () => {
|
||||
const { storage } = createSecuritySolutionStorageMock();
|
||||
const pollForSignalIndexMock = jest.fn();
|
||||
let wrapper: ReactWrapper;
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
|
||||
|
@ -100,8 +98,12 @@ describe('Sourcerer component', () => {
|
|||
(useSignalHelpers as jest.Mock).mockReturnValue({ signalIndexNeedsInit: false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (wrapper && wrapper.exists()) wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders data view title', () => {
|
||||
const wrapper = mount(
|
||||
wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer {...defaultProps} />
|
||||
</TestProviders>
|
||||
|
@ -117,7 +119,7 @@ describe('Sourcerer component', () => {
|
|||
...defaultProps,
|
||||
showAlertsOnlyCheckbox: true,
|
||||
};
|
||||
const wrapper = mount(
|
||||
wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer {...testProps} />
|
||||
</TestProviders>
|
||||
|
@ -129,7 +131,7 @@ describe('Sourcerer component', () => {
|
|||
});
|
||||
|
||||
it('renders tooltip', () => {
|
||||
const wrapper = mount(
|
||||
wrapper = mount(
|
||||
<TestProviders>
|
||||
<Sourcerer {...defaultProps} />
|
||||
</TestProviders>
|
||||
|
@ -140,7 +142,7 @@ describe('Sourcerer component', () => {
|
|||
});
|
||||
|
||||
it('renders popover button inside tooltip', () => {
|
||||
const wrapper = mount(
|
||||
wrapper = mount(
|
||||
<TestProviders>
|
||||
<Sourcerer {...defaultProps} />
|
||||
</TestProviders>
|
||||
|
@ -156,7 +158,7 @@ describe('Sourcerer component', () => {
|
|||
// Using props callback instead of simulating clicks,
|
||||
// because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects
|
||||
it('Mounts with all options selected', () => {
|
||||
const wrapper = mount(
|
||||
wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer {...defaultProps} />
|
||||
</TestProviders>
|
||||
|
@ -206,7 +208,7 @@ describe('Sourcerer component', () => {
|
|||
kibanaObservable,
|
||||
storage
|
||||
);
|
||||
const wrapper = mount(
|
||||
wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer {...defaultProps} />
|
||||
</TestProviders>
|
||||
|
@ -256,7 +258,7 @@ describe('Sourcerer component', () => {
|
|||
kibanaObservable,
|
||||
storage
|
||||
);
|
||||
const wrapper = mount(
|
||||
wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer {...defaultProps} />
|
||||
</TestProviders>
|
||||
|
@ -305,7 +307,7 @@ describe('Sourcerer component', () => {
|
|||
};
|
||||
|
||||
store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
|
||||
const wrapper = mount(
|
||||
wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer {...defaultProps} />
|
||||
</TestProviders>
|
||||
|
@ -318,7 +320,7 @@ describe('Sourcerer component', () => {
|
|||
optionsSelected: true,
|
||||
});
|
||||
});
|
||||
it('Mounts with multiple options selected - timeline', () => {
|
||||
it('Mounts with multiple options selected - timeline', async () => {
|
||||
const state2 = {
|
||||
...mockGlobalState,
|
||||
sourcerer: {
|
||||
|
@ -350,17 +352,22 @@ describe('Sourcerer component', () => {
|
|||
};
|
||||
|
||||
store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
|
||||
const wrapper = mount(
|
||||
const { getByTestId, queryByTitle, queryAllByTestId } = render(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer scope={sourcererModel.SourcererScopeName.timeline} />
|
||||
</TestProviders>
|
||||
);
|
||||
wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click');
|
||||
wrapper.find(`[data-test-subj="comboBoxInput"]`).first().simulate('click');
|
||||
expect(checkOptionsAndSelections(wrapper, patternList.slice(0, 2))).toEqual({
|
||||
// should show every option except fakebeat-*
|
||||
availableOptionCount: title.split(',').length - 2,
|
||||
optionsSelected: true,
|
||||
|
||||
fireEvent.click(getByTestId('timeline-sourcerer-trigger'));
|
||||
await waitFor(() => {
|
||||
for (const pattern of patternList.slice(0, 2)) {
|
||||
expect(queryByTitle(pattern)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
fireEvent.click(getByTestId('comboBoxInput'));
|
||||
await waitFor(() => {
|
||||
expect(queryAllByTestId('sourcerer-combo-option')).toHaveLength(title.split(',').length - 2);
|
||||
});
|
||||
});
|
||||
it('onSave dispatches setSelectedDataView', async () => {
|
||||
|
@ -392,7 +399,7 @@ describe('Sourcerer component', () => {
|
|||
kibanaObservable,
|
||||
storage
|
||||
);
|
||||
const wrapper = mount(
|
||||
wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer {...defaultProps} />
|
||||
</TestProviders>
|
||||
|
@ -450,7 +457,7 @@ describe('Sourcerer component', () => {
|
|||
storage
|
||||
);
|
||||
|
||||
const wrapper = mount(
|
||||
wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer {...defaultProps} />
|
||||
</TestProviders>
|
||||
|
@ -464,7 +471,7 @@ describe('Sourcerer component', () => {
|
|||
});
|
||||
|
||||
it('resets to default index pattern', async () => {
|
||||
const wrapper = mount(
|
||||
wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer {...defaultProps} />
|
||||
</TestProviders>
|
||||
|
@ -517,7 +524,7 @@ describe('Sourcerer component', () => {
|
|||
kibanaObservable,
|
||||
storage
|
||||
);
|
||||
const wrapper = mount(
|
||||
wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer {...defaultProps} />
|
||||
</TestProviders>
|
||||
|
@ -526,7 +533,14 @@ describe('Sourcerer component', () => {
|
|||
wrapper.find('[data-test-subj="comboBoxClearButton"]').first().simulate('click');
|
||||
expect(wrapper.find('[data-test-subj="sourcerer-save"]').first().prop('disabled')).toBeTruthy();
|
||||
});
|
||||
it('Does display signals index on timeline sourcerer', () => {
|
||||
it('Does display signals index on timeline sourcerer', async () => {
|
||||
/*
|
||||
* Since both enzyme and RTL share JSDOM when running these tests,
|
||||
* and enzyme does not clears jsdom after each test, because of this
|
||||
* `screen` of RTL does not work as expect, please avoid using screen
|
||||
* till all the tests have been converted to RTL
|
||||
*
|
||||
* */
|
||||
const state2 = {
|
||||
...mockGlobalState,
|
||||
sourcerer: {
|
||||
|
@ -559,16 +573,20 @@ describe('Sourcerer component', () => {
|
|||
};
|
||||
|
||||
store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
|
||||
const wrapper = mount(
|
||||
const el = render(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer scope={sourcererModel.SourcererScopeName.timeline} />
|
||||
</TestProviders>
|
||||
);
|
||||
wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click');
|
||||
wrapper.find(`[data-test-subj="comboBoxToggleListButton"]`).first().simulate('click');
|
||||
expect(wrapper.find(`[data-test-subj="sourcerer-combo-option"]`).at(0).text()).toEqual(
|
||||
mockGlobalState.sourcerer.signalIndexName
|
||||
);
|
||||
|
||||
fireEvent.click(el.getByTestId('timeline-sourcerer-trigger'));
|
||||
fireEvent.click(el.getByTestId('comboBoxToggleListButton'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(el.queryAllByTestId('sourcerer-combo-option')[0].textContent).toBe(
|
||||
mockGlobalState.sourcerer.signalIndexName
|
||||
);
|
||||
});
|
||||
});
|
||||
it('Does not display signals index on default sourcerer', () => {
|
||||
const state2 = {
|
||||
|
@ -603,7 +621,7 @@ describe('Sourcerer component', () => {
|
|||
};
|
||||
|
||||
store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
|
||||
const wrapper = mount(
|
||||
wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer {...defaultProps} />
|
||||
</TestProviders>
|
||||
|
@ -677,617 +695,3 @@ describe('Sourcerer component', () => {
|
|||
expect(pollForSignalIndexMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sourcerer on alerts page or rules details page', () => {
|
||||
let wrapper: ReactWrapper;
|
||||
const { storage } = createSecuritySolutionStorageMock();
|
||||
store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
|
||||
const testProps = {
|
||||
scope: sourcererModel.SourcererScopeName.detections,
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer {...testProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
wrapper.find(`[data-test-subj="sourcerer-trigger"]`).first().simulate('click');
|
||||
wrapper.find(`[data-test-subj="sourcerer-advanced-options-toggle"]`).first().simulate('click');
|
||||
});
|
||||
|
||||
it('renders an alerts badge in sourcerer button', () => {
|
||||
expect(wrapper.find(`[data-test-subj="sourcerer-alerts-badge"]`).first().text()).toEqual(
|
||||
'Alerts'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a callout', () => {
|
||||
expect(wrapper.find(`[data-test-subj="sourcerer-callout"]`).first().text()).toEqual(
|
||||
'Data view cannot be modified on this page'
|
||||
);
|
||||
});
|
||||
|
||||
it('disable data view selector', () => {
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="sourcerer-select"]`).first().prop('disabled')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('data view selector is default to Security Data View', () => {
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="sourcerer-select"]`).first().prop('valueOfSelected')
|
||||
).toEqual('security-solution');
|
||||
});
|
||||
|
||||
it('renders an alert badge in data view selector', () => {
|
||||
expect(wrapper.find(`[data-test-subj="security-alerts-option-badge"]`).first().text()).toEqual(
|
||||
'Alerts'
|
||||
);
|
||||
});
|
||||
|
||||
it('disable index pattern selector', () => {
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="sourcerer-combo-box"]`).first().prop('disabled')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows signal index as index pattern option', () => {
|
||||
expect(wrapper.find(`[data-test-subj="sourcerer-combo-box"]`).first().prop('options')).toEqual([
|
||||
{ disabled: false, label: '.siem-signals-spacename', value: '.siem-signals-spacename' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not render reset button', () => {
|
||||
expect(wrapper.find(`[data-test-subj="sourcerer-reset"]`).exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('does not render save button', () => {
|
||||
expect(wrapper.find(`[data-test-subj="sourcerer-save"]`).exists()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeline sourcerer', () => {
|
||||
let wrapper: ReactWrapper;
|
||||
const { storage } = createSecuritySolutionStorageMock();
|
||||
store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
|
||||
const testProps = {
|
||||
scope: sourcererModel.SourcererScopeName.timeline,
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
(useSourcererDataView as jest.Mock).mockReturnValue(sourcererDataView);
|
||||
wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer {...testProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click');
|
||||
wrapper
|
||||
.find(
|
||||
`[data-test-subj="timeline-sourcerer-popover"] [data-test-subj="sourcerer-advanced-options-toggle"]`
|
||||
)
|
||||
.first()
|
||||
.simulate('click');
|
||||
});
|
||||
|
||||
it('renders "alerts only" checkbox, unchecked', () => {
|
||||
wrapper
|
||||
.find(
|
||||
`[data-test-subj="timeline-sourcerer-popover"] [data-test-subj="sourcerer-alert-only-checkbox"]`
|
||||
)
|
||||
.first()
|
||||
.simulate('click');
|
||||
expect(wrapper.find(`[data-test-subj="sourcerer-alert-only-checkbox"]`).first().text()).toEqual(
|
||||
'Show only detection alerts'
|
||||
);
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="sourcerer-alert-only-checkbox"] input`).first().prop('checked')
|
||||
).toEqual(false);
|
||||
});
|
||||
|
||||
it('data view selector is enabled', () => {
|
||||
expect(
|
||||
wrapper
|
||||
.find(`[data-test-subj="timeline-sourcerer-popover"] [data-test-subj="sourcerer-select"]`)
|
||||
.first()
|
||||
.prop('disabled')
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('data view selector is default to Security Default Data View', () => {
|
||||
expect(
|
||||
wrapper
|
||||
.find(`[data-test-subj="timeline-sourcerer-popover"] [data-test-subj="sourcerer-select"]`)
|
||||
.first()
|
||||
.prop('valueOfSelected')
|
||||
).toEqual('security-solution');
|
||||
});
|
||||
|
||||
it('index pattern selector is enabled', () => {
|
||||
expect(
|
||||
wrapper
|
||||
.find(
|
||||
`[data-test-subj="timeline-sourcerer-popover"] [data-test-subj="sourcerer-combo-box"]`
|
||||
)
|
||||
.first()
|
||||
.prop('disabled')
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('render reset button', () => {
|
||||
expect(wrapper.find(`[data-test-subj="sourcerer-reset"]`).exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('render save button', () => {
|
||||
expect(wrapper.find(`[data-test-subj="sourcerer-save"]`).exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Checks box when only alerts index is selected in timeline', () => {
|
||||
const state2 = {
|
||||
...mockGlobalState,
|
||||
sourcerer: {
|
||||
...mockGlobalState.sourcerer,
|
||||
sourcererScopes: {
|
||||
...mockGlobalState.sourcerer.sourcererScopes,
|
||||
[SourcererScopeName.timeline]: {
|
||||
...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline],
|
||||
loading: false,
|
||||
selectedDataViewId: id,
|
||||
selectedPatterns: [`${mockGlobalState.sourcerer.signalIndexName}`],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
store = createStore(
|
||||
state2,
|
||||
SUB_PLUGINS_REDUCER,
|
||||
|
||||
kibanaObservable,
|
||||
storage
|
||||
);
|
||||
|
||||
wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer scope={sourcererModel.SourcererScopeName.timeline} />
|
||||
</TestProviders>
|
||||
);
|
||||
wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click');
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="sourcerer-alert-only-checkbox"] input`).first().prop('checked')
|
||||
).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sourcerer integration tests', () => {
|
||||
const state = {
|
||||
...mockGlobalState,
|
||||
sourcerer: {
|
||||
...mockGlobalState.sourcerer,
|
||||
kibanaDataViews: [
|
||||
mockGlobalState.sourcerer.defaultDataView,
|
||||
{
|
||||
...mockGlobalState.sourcerer.defaultDataView,
|
||||
id: '1234',
|
||||
title: 'fakebeat-*,neatbeat-*',
|
||||
patternList: ['fakebeat-*'],
|
||||
},
|
||||
],
|
||||
sourcererScopes: {
|
||||
...mockGlobalState.sourcerer.sourcererScopes,
|
||||
[SourcererScopeName.default]: {
|
||||
...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.default],
|
||||
loading: false,
|
||||
selectedDataViewId: id,
|
||||
selectedPatterns: patternListNoSignals.slice(0, 2),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { storage } = createSecuritySolutionStorageMock();
|
||||
|
||||
beforeEach(() => {
|
||||
(useSourcererDataView as jest.Mock).mockReturnValue(sourcererDataView);
|
||||
store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('Selects a different index pattern', async () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
wrapper.find(`[data-test-subj="sourcerer-trigger"]`).first().simulate('click');
|
||||
wrapper.find(`button[data-test-subj="sourcerer-select"]`).first().simulate('click');
|
||||
|
||||
wrapper.find(`[data-test-subj="dataView-option-super"]`).first().simulate('click');
|
||||
expect(checkOptionsAndSelections(wrapper, ['fakebeat-*'])).toEqual({
|
||||
availableOptionCount: 0,
|
||||
optionsSelected: true,
|
||||
});
|
||||
wrapper.find(`button[data-test-subj="sourcerer-save"]`).first().simulate('click');
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
sourcererActions.setSelectedDataView({
|
||||
id: SourcererScopeName.default,
|
||||
selectedDataViewId: '1234',
|
||||
selectedPatterns: ['fakebeat-*'],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('No data', () => {
|
||||
const mockNoIndicesState = {
|
||||
...mockGlobalState,
|
||||
sourcerer: {
|
||||
...initialSourcererState,
|
||||
},
|
||||
};
|
||||
|
||||
const { storage } = createSecuritySolutionStorageMock();
|
||||
|
||||
beforeEach(() => {
|
||||
(useSourcererDataView as jest.Mock).mockReturnValue({
|
||||
...sourcererDataView,
|
||||
indicesExist: false,
|
||||
});
|
||||
store = createStore(mockNoIndicesState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('Hide sourcerer - default ', () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(wrapper.find(`[data-test-subj="sourcerer-trigger"]`).exists()).toEqual(false);
|
||||
});
|
||||
test('Hide sourcerer - detections ', () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer scope={sourcererModel.SourcererScopeName.detections} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(wrapper.find(`[data-test-subj="sourcerer-trigger"]`).exists()).toEqual(false);
|
||||
});
|
||||
test('Hide sourcerer - timeline ', () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer scope={sourcererModel.SourcererScopeName.timeline} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).exists()).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update available', () => {
|
||||
const { storage } = createSecuritySolutionStorageMock();
|
||||
const state2 = {
|
||||
...mockGlobalState,
|
||||
sourcerer: {
|
||||
...mockGlobalState.sourcerer,
|
||||
kibanaDataViews: [
|
||||
mockGlobalState.sourcerer.defaultDataView,
|
||||
{
|
||||
...mockGlobalState.sourcerer.defaultDataView,
|
||||
id: '1234',
|
||||
title: 'auditbeat-*',
|
||||
patternList: ['auditbeat-*'],
|
||||
},
|
||||
{
|
||||
...mockGlobalState.sourcerer.defaultDataView,
|
||||
id: '12347',
|
||||
title: 'packetbeat-*',
|
||||
patternList: ['packetbeat-*'],
|
||||
},
|
||||
],
|
||||
sourcererScopes: {
|
||||
...mockGlobalState.sourcerer.sourcererScopes,
|
||||
[SourcererScopeName.timeline]: {
|
||||
...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline],
|
||||
loading: false,
|
||||
patternList,
|
||||
selectedDataViewId: null,
|
||||
selectedPatterns: ['myFakebeat-*'],
|
||||
missingPatterns: ['myFakebeat-*'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let wrapper: ReactWrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
(useSourcererDataView as jest.Mock).mockReturnValue({
|
||||
...sourcererDataView,
|
||||
activePatterns: ['myFakebeat-*'],
|
||||
});
|
||||
store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
|
||||
|
||||
wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer scope={sourcererModel.SourcererScopeName.timeline} />
|
||||
</TestProviders>
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('Show Update available label', () => {
|
||||
expect(wrapper.find(`[data-test-subj="sourcerer-deprecated-badge"]`).exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Show correct tooltip', () => {
|
||||
expect(wrapper.find(`[data-test-subj="sourcerer-tooltip"]`).prop('content')).toEqual(
|
||||
'myFakebeat-*'
|
||||
);
|
||||
});
|
||||
|
||||
test('Show UpdateDefaultDataViewModal', () => {
|
||||
wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click');
|
||||
|
||||
wrapper.find(`button[data-test-subj="sourcerer-deprecated-update"]`).first().simulate('click');
|
||||
|
||||
expect(wrapper.find(`UpdateDefaultDataViewModal`).prop('isShowing')).toEqual(true);
|
||||
});
|
||||
|
||||
test('Show UpdateDefaultDataViewModal Callout', () => {
|
||||
wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click');
|
||||
|
||||
wrapper.find(`button[data-test-subj="sourcerer-deprecated-update"]`).first().simulate('click');
|
||||
|
||||
expect(wrapper.find(`[data-test-subj="sourcerer-deprecated-callout"]`).first().text()).toEqual(
|
||||
'This timeline uses a legacy data view selector'
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="sourcerer-current-patterns-message"]`).first().text()
|
||||
).toEqual('The active index patterns in this timeline are: myFakebeat-*');
|
||||
|
||||
expect(wrapper.find(`[data-test-subj="sourcerer-deprecated-message"]`).first().text()).toEqual(
|
||||
"We have preserved your timeline by creating a temporary data view. If you'd like to modify your data, we can recreate your temporary data view with the new data view selector. You can also manually select a data view here."
|
||||
);
|
||||
});
|
||||
|
||||
test('Show Add index pattern in UpdateDefaultDataViewModal', () => {
|
||||
wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click');
|
||||
|
||||
wrapper.find(`button[data-test-subj="sourcerer-deprecated-update"]`).first().simulate('click');
|
||||
|
||||
expect(wrapper.find(`button[data-test-subj="sourcerer-update-data-view"]`).text()).toEqual(
|
||||
'Add index pattern'
|
||||
);
|
||||
});
|
||||
|
||||
test('Set all the index patterns from legacy timeline to sourcerer, after clicking on "Add index pattern"', async () => {
|
||||
wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click');
|
||||
|
||||
wrapper.find(`button[data-test-subj="sourcerer-deprecated-update"]`).first().simulate('click');
|
||||
|
||||
wrapper.find(`button[data-test-subj="sourcerer-update-data-view"]`).simulate('click');
|
||||
|
||||
await waitFor(() => wrapper.update());
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
sourcererActions.setSelectedDataView({
|
||||
id: SourcererScopeName.timeline,
|
||||
selectedDataViewId: 'security-solution',
|
||||
selectedPatterns: ['myFakebeat-*'],
|
||||
shouldValidateSelectedPatterns: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update available for timeline template', () => {
|
||||
const { storage } = createSecuritySolutionStorageMock();
|
||||
const state2 = {
|
||||
...mockGlobalState,
|
||||
timeline: {
|
||||
...mockGlobalState.timeline,
|
||||
timelineById: {
|
||||
...mockGlobalState.timeline.timelineById,
|
||||
[TimelineId.active]: {
|
||||
...mockGlobalState.timeline.timelineById.test,
|
||||
timelineType: TimelineType.template,
|
||||
},
|
||||
},
|
||||
},
|
||||
sourcerer: {
|
||||
...mockGlobalState.sourcerer,
|
||||
kibanaDataViews: [
|
||||
mockGlobalState.sourcerer.defaultDataView,
|
||||
{
|
||||
...mockGlobalState.sourcerer.defaultDataView,
|
||||
id: '1234',
|
||||
title: 'auditbeat-*',
|
||||
patternList: ['auditbeat-*'],
|
||||
},
|
||||
{
|
||||
...mockGlobalState.sourcerer.defaultDataView,
|
||||
id: '12347',
|
||||
title: 'packetbeat-*',
|
||||
patternList: ['packetbeat-*'],
|
||||
},
|
||||
],
|
||||
sourcererScopes: {
|
||||
...mockGlobalState.sourcerer.sourcererScopes,
|
||||
[SourcererScopeName.timeline]: {
|
||||
...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline],
|
||||
loading: false,
|
||||
patternList,
|
||||
selectedDataViewId: null,
|
||||
selectedPatterns: ['myFakebeat-*'],
|
||||
missingPatterns: ['myFakebeat-*'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let wrapper: ReactWrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
(useSourcererDataView as jest.Mock).mockReturnValue({
|
||||
...sourcererDataView,
|
||||
activePatterns: ['myFakebeat-*'],
|
||||
});
|
||||
store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
|
||||
|
||||
wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer scope={sourcererModel.SourcererScopeName.timeline} />
|
||||
</TestProviders>
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('Show UpdateDefaultDataViewModal CallOut', () => {
|
||||
wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click');
|
||||
|
||||
wrapper.find(`button[data-test-subj="sourcerer-deprecated-update"]`).first().simulate('click');
|
||||
|
||||
expect(wrapper.find(`[data-test-subj="sourcerer-deprecated-callout"]`).first().text()).toEqual(
|
||||
'This timeline template uses a legacy data view selector'
|
||||
);
|
||||
|
||||
expect(wrapper.find(`[data-test-subj="sourcerer-deprecated-message"]`).first().text()).toEqual(
|
||||
"We have preserved your timeline template by creating a temporary data view. If you'd like to modify your data, we can recreate your temporary data view with the new data view selector. You can also manually select a data view here."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Missing index patterns', () => {
|
||||
const { storage } = createSecuritySolutionStorageMock();
|
||||
const state2 = {
|
||||
...mockGlobalState,
|
||||
timeline: {
|
||||
...mockGlobalState.timeline,
|
||||
timelineById: {
|
||||
...mockGlobalState.timeline.timelineById,
|
||||
[TimelineId.active]: {
|
||||
...mockGlobalState.timeline.timelineById.test,
|
||||
timelineType: TimelineType.template,
|
||||
},
|
||||
},
|
||||
},
|
||||
sourcerer: {
|
||||
...mockGlobalState.sourcerer,
|
||||
kibanaDataViews: [
|
||||
mockGlobalState.sourcerer.defaultDataView,
|
||||
{
|
||||
...mockGlobalState.sourcerer.defaultDataView,
|
||||
id: '1234',
|
||||
title: 'auditbeat-*',
|
||||
patternList: ['auditbeat-*'],
|
||||
},
|
||||
{
|
||||
...mockGlobalState.sourcerer.defaultDataView,
|
||||
id: '12347',
|
||||
title: 'packetbeat-*',
|
||||
patternList: ['packetbeat-*'],
|
||||
},
|
||||
],
|
||||
sourcererScopes: {
|
||||
...mockGlobalState.sourcerer.sourcererScopes,
|
||||
[SourcererScopeName.timeline]: {
|
||||
...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline],
|
||||
loading: false,
|
||||
patternList,
|
||||
selectedDataViewId: 'fake-data-view-id',
|
||||
selectedPatterns: ['myFakebeat-*'],
|
||||
missingPatterns: ['myFakebeat-*'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let wrapper: ReactWrapper;
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('Show UpdateDefaultDataViewModal CallOut for timeline', () => {
|
||||
(useSourcererDataView as jest.Mock).mockReturnValue({
|
||||
...sourcererDataView,
|
||||
activePatterns: ['myFakebeat-*'],
|
||||
});
|
||||
const state3 = cloneDeep(state2);
|
||||
state3.timeline.timelineById[TimelineId.active].timelineType = TimelineType.default;
|
||||
store = createStore(state3, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
|
||||
|
||||
wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer scope={sourcererModel.SourcererScopeName.timeline} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click');
|
||||
|
||||
wrapper.find(`button[data-test-subj="sourcerer-deprecated-update"]`).first().simulate('click');
|
||||
|
||||
expect(wrapper.find(`[data-test-subj="sourcerer-deprecated-callout"]`).first().text()).toEqual(
|
||||
'This timeline is out of date with the Security Data View'
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="sourcerer-current-patterns-message"]`).first().text()
|
||||
).toEqual('The active index patterns in this timeline are: myFakebeat-*');
|
||||
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="sourcerer-missing-patterns-callout"]`).first().text()
|
||||
).toEqual('Security Data View is missing the following index patterns: myFakebeat-*');
|
||||
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="sourcerer-missing-patterns-message"]`).first().text()
|
||||
).toEqual(
|
||||
"We have preserved your timeline by creating a temporary data view. If you'd like to modify your data, we can add the missing index patterns to the Security Data View. You can also manually select a data view here."
|
||||
);
|
||||
});
|
||||
|
||||
test('Show UpdateDefaultDataViewModal CallOut for timeline template', () => {
|
||||
(useSourcererDataView as jest.Mock).mockReturnValue({
|
||||
...sourcererDataView,
|
||||
activePatterns: ['myFakebeat-*'],
|
||||
});
|
||||
store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
|
||||
|
||||
wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer scope={sourcererModel.SourcererScopeName.timeline} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click');
|
||||
|
||||
wrapper.find(`button[data-test-subj="sourcerer-deprecated-update"]`).first().simulate('click');
|
||||
|
||||
expect(wrapper.find(`[data-test-subj="sourcerer-deprecated-callout"]`).first().text()).toEqual(
|
||||
'This timeline template is out of date with the Security Data View'
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="sourcerer-current-patterns-message"]`).first().text()
|
||||
).toEqual('The active index patterns in this timeline template are: myFakebeat-*');
|
||||
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="sourcerer-missing-patterns-callout"]`).first().text()
|
||||
).toEqual('Security Data View is missing the following index patterns: myFakebeat-*');
|
||||
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="sourcerer-missing-patterns-message"]`).first().text()
|
||||
).toEqual(
|
||||
"We have preserved your timeline template by creating a temporary data view. If you'd like to modify your data, we can add the missing index patterns to the Security Data View. You can also manually select a data view here."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -25,7 +25,7 @@ import { useDeepEqualSelector } from '../../hooks/use_selector';
|
|||
import type { SourcererUrlState } from '../../store/sourcerer/model';
|
||||
import { SourcererScopeName } from '../../store/sourcerer/model';
|
||||
import { usePickIndexPatterns } from './use_pick_index_patterns';
|
||||
import { FormRow, PopoverContent, StyledButton, StyledFormRow } from './helpers';
|
||||
import { FormRow, PopoverContent, StyledButtonEmpty, StyledFormRow } from './helpers';
|
||||
import { TemporarySourcerer } from './temporary';
|
||||
import { useSourcererDataView } from '../../containers/sourcerer';
|
||||
import { useUpdateDataView } from './use_update_data_view';
|
||||
|
@ -338,14 +338,14 @@ export const Sourcerer = React.memo<SourcererComponentProps>(({ scope: scopeId }
|
|||
)}
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
<StyledButton
|
||||
<StyledButtonEmpty
|
||||
color="text"
|
||||
data-test-subj="sourcerer-advanced-options-toggle"
|
||||
iconType={expandAdvancedOptions ? 'arrowDown' : 'arrowRight'}
|
||||
onClick={onExpandAdvancedOptionsClicked}
|
||||
>
|
||||
{i18n.INDEX_PATTERNS_ADVANCED_OPTIONS_TITLE}
|
||||
</StyledButton>
|
||||
</StyledButtonEmpty>
|
||||
{expandAdvancedOptions && <EuiSpacer size="m" />}
|
||||
<FormRow
|
||||
isDisabled={loadingIndexPatterns}
|
||||
|
|
|
@ -0,0 +1,537 @@
|
|||
/*
|
||||
* 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 from 'react';
|
||||
import type { ReactWrapper } from 'enzyme';
|
||||
import { mount } from 'enzyme';
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
import { initialSourcererState, SourcererScopeName } from '../../store/sourcerer/model';
|
||||
import { Sourcerer } from '.';
|
||||
import { sourcererActions, sourcererModel } from '../../store/sourcerer';
|
||||
import {
|
||||
createSecuritySolutionStorageMock,
|
||||
kibanaObservable,
|
||||
mockGlobalState,
|
||||
SUB_PLUGINS_REDUCER,
|
||||
TestProviders,
|
||||
} from '../../mock';
|
||||
import { createStore } from '../../store';
|
||||
import { useSourcererDataView } from '../../containers/sourcerer';
|
||||
import { useSignalHelpers } from '../../containers/sourcerer/use_signal_helpers';
|
||||
import { TimelineId } from '../../../../common/types/timeline';
|
||||
import { TimelineType } from '../../../../common/api/timeline';
|
||||
import { sortWithExcludesAtEnd } from '../../../../common/utils/sourcerer';
|
||||
import { render, fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
|
||||
jest.mock('../../containers/sourcerer');
|
||||
jest.mock('../../containers/sourcerer/use_signal_helpers');
|
||||
const mockUseUpdateDataView = jest.fn().mockReturnValue(() => true);
|
||||
jest.mock('./use_update_data_view', () => ({
|
||||
useUpdateDataView: () => mockUseUpdateDataView,
|
||||
}));
|
||||
jest.mock('react-redux', () => {
|
||||
const original = jest.requireActual('react-redux');
|
||||
|
||||
return {
|
||||
...original,
|
||||
useDispatch: () => mockDispatch,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@kbn/kibana-react-plugin/public', () => {
|
||||
const original = jest.requireActual('@kbn/kibana-react-plugin/public');
|
||||
|
||||
return {
|
||||
...original,
|
||||
toMountPoint: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockUpdateUrlParam = jest.fn();
|
||||
jest.mock('../../utils/global_query_string', () => {
|
||||
const original = jest.requireActual('../../utils/global_query_string');
|
||||
|
||||
return {
|
||||
...original,
|
||||
useUpdateUrlParam: () => mockUpdateUrlParam,
|
||||
};
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
scope: sourcererModel.SourcererScopeName.default,
|
||||
};
|
||||
|
||||
const checkOptionsAndSelections = (wrapper: ReactWrapper, patterns: string[]) => ({
|
||||
availableOptionCount:
|
||||
wrapper.find('List').length > 0 ? wrapper.find('List').prop('itemCount') : 0,
|
||||
optionsSelected: patterns.every((pattern) =>
|
||||
wrapper.find(`[data-test-subj="sourcerer-combo-box"] span[title="${pattern}"]`).first().exists()
|
||||
),
|
||||
});
|
||||
|
||||
const { id, patternList } = mockGlobalState.sourcerer.defaultDataView;
|
||||
|
||||
const patternListNoSignals = sortWithExcludesAtEnd(
|
||||
patternList.filter((p) => p !== mockGlobalState.sourcerer.signalIndexName)
|
||||
);
|
||||
let store: ReturnType<typeof createStore>;
|
||||
const sourcererDataView = {
|
||||
indicesExist: true,
|
||||
loading: false,
|
||||
};
|
||||
|
||||
describe('No data', () => {
|
||||
const mockNoIndicesState = {
|
||||
...mockGlobalState,
|
||||
sourcerer: {
|
||||
...initialSourcererState,
|
||||
},
|
||||
};
|
||||
|
||||
const { storage } = createSecuritySolutionStorageMock();
|
||||
const pollForSignalIndexMock = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
(useSourcererDataView as jest.Mock).mockReturnValue({
|
||||
...sourcererDataView,
|
||||
indicesExist: false,
|
||||
});
|
||||
(useSignalHelpers as jest.Mock).mockReturnValue({
|
||||
pollForSignalIndex: pollForSignalIndexMock,
|
||||
signalIndexNeedsInit: false,
|
||||
});
|
||||
store = createStore(mockNoIndicesState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('Hide sourcerer - default ', () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(wrapper.find(`[data-test-subj="sourcerer-trigger"]`).exists()).toEqual(false);
|
||||
});
|
||||
test('Hide sourcerer - detections ', () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer scope={sourcererModel.SourcererScopeName.detections} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(wrapper.find(`[data-test-subj="sourcerer-trigger"]`).exists()).toEqual(false);
|
||||
});
|
||||
test('Hide sourcerer - timeline ', () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer scope={sourcererModel.SourcererScopeName.timeline} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).exists()).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update available', () => {
|
||||
const { storage } = createSecuritySolutionStorageMock();
|
||||
const state2 = {
|
||||
...mockGlobalState,
|
||||
sourcerer: {
|
||||
...mockGlobalState.sourcerer,
|
||||
kibanaDataViews: [
|
||||
mockGlobalState.sourcerer.defaultDataView,
|
||||
{
|
||||
...mockGlobalState.sourcerer.defaultDataView,
|
||||
id: '1234',
|
||||
title: 'auditbeat-*',
|
||||
patternList: ['auditbeat-*'],
|
||||
},
|
||||
{
|
||||
...mockGlobalState.sourcerer.defaultDataView,
|
||||
id: '12347',
|
||||
title: 'packetbeat-*',
|
||||
patternList: ['packetbeat-*'],
|
||||
},
|
||||
],
|
||||
sourcererScopes: {
|
||||
...mockGlobalState.sourcerer.sourcererScopes,
|
||||
[SourcererScopeName.timeline]: {
|
||||
...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline],
|
||||
loading: false,
|
||||
patternList,
|
||||
selectedDataViewId: null,
|
||||
selectedPatterns: ['myFakebeat-*'],
|
||||
missingPatterns: ['myFakebeat-*'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const pollForSignalIndexMock = jest.fn();
|
||||
beforeEach(() => {
|
||||
(useSignalHelpers as jest.Mock).mockReturnValue({
|
||||
pollForSignalIndex: pollForSignalIndexMock,
|
||||
signalIndexNeedsInit: false,
|
||||
});
|
||||
(useSourcererDataView as jest.Mock).mockReturnValue({
|
||||
...sourcererDataView,
|
||||
activePatterns: ['myFakebeat-*'],
|
||||
});
|
||||
store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
|
||||
|
||||
render(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer scope={sourcererModel.SourcererScopeName.timeline} />
|
||||
</TestProviders>
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('Show Update available label', () => {
|
||||
expect(screen.getByTestId('sourcerer-deprecated-badge')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Show correct tooltip', async () => {
|
||||
fireEvent.mouseOver(screen.getByTestId('timeline-sourcerer-trigger'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('sourcerer-tooltip').textContent).toBe('myFakebeat-*');
|
||||
});
|
||||
});
|
||||
|
||||
test('Show UpdateDefaultDataViewModal', () => {
|
||||
fireEvent.click(screen.queryAllByTestId('timeline-sourcerer-trigger')[0]);
|
||||
|
||||
fireEvent.click(screen.queryAllByTestId('sourcerer-deprecated-update')[0]);
|
||||
|
||||
expect(screen.getByTestId('sourcerer-update-data-view-modal')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Show UpdateDefaultDataViewModal Callout', () => {
|
||||
fireEvent.click(screen.queryAllByTestId('timeline-sourcerer-trigger')[0]);
|
||||
|
||||
fireEvent.click(screen.queryAllByTestId('sourcerer-deprecated-update')[0]);
|
||||
|
||||
expect(screen.queryAllByTestId('sourcerer-deprecated-callout')[0].textContent).toBe(
|
||||
'This timeline uses a legacy data view selector'
|
||||
);
|
||||
|
||||
expect(screen.queryAllByTestId('sourcerer-current-patterns-message')[0].textContent).toBe(
|
||||
'The active index patterns in this timeline are: myFakebeat-*'
|
||||
);
|
||||
|
||||
expect(screen.queryAllByTestId('sourcerer-deprecated-message')[0].textContent).toBe(
|
||||
"We have preserved your timeline by creating a temporary data view. If you'd like to modify your data, we can recreate your temporary data view with the new data view selector. You can also manually select a data view here."
|
||||
);
|
||||
});
|
||||
|
||||
test('Show Add index pattern in UpdateDefaultDataViewModal', () => {
|
||||
fireEvent.click(screen.queryAllByTestId('timeline-sourcerer-trigger')[0]);
|
||||
|
||||
fireEvent.click(screen.queryAllByTestId('sourcerer-deprecated-update')[0]);
|
||||
|
||||
expect(screen.queryAllByTestId('sourcerer-update-data-view')[0].textContent).toBe(
|
||||
'Add index pattern'
|
||||
);
|
||||
});
|
||||
|
||||
test('Set all the index patterns from legacy timeline to sourcerer, after clicking on "Add index pattern"', async () => {
|
||||
fireEvent.click(screen.queryAllByTestId('timeline-sourcerer-trigger')[0]);
|
||||
|
||||
fireEvent.click(screen.queryAllByTestId('sourcerer-deprecated-update')[0]);
|
||||
|
||||
fireEvent.click(screen.queryAllByTestId('sourcerer-update-data-view')[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
sourcererActions.setSelectedDataView({
|
||||
id: SourcererScopeName.timeline,
|
||||
selectedDataViewId: 'security-solution',
|
||||
selectedPatterns: ['myFakebeat-*'],
|
||||
shouldValidateSelectedPatterns: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update available for timeline template', () => {
|
||||
const { storage } = createSecuritySolutionStorageMock();
|
||||
const state2 = {
|
||||
...mockGlobalState,
|
||||
timeline: {
|
||||
...mockGlobalState.timeline,
|
||||
timelineById: {
|
||||
...mockGlobalState.timeline.timelineById,
|
||||
[TimelineId.active]: {
|
||||
...mockGlobalState.timeline.timelineById.test,
|
||||
timelineType: TimelineType.template,
|
||||
},
|
||||
},
|
||||
},
|
||||
sourcerer: {
|
||||
...mockGlobalState.sourcerer,
|
||||
kibanaDataViews: [
|
||||
mockGlobalState.sourcerer.defaultDataView,
|
||||
{
|
||||
...mockGlobalState.sourcerer.defaultDataView,
|
||||
id: '1234',
|
||||
title: 'auditbeat-*',
|
||||
patternList: ['auditbeat-*'],
|
||||
},
|
||||
{
|
||||
...mockGlobalState.sourcerer.defaultDataView,
|
||||
id: '12347',
|
||||
title: 'packetbeat-*',
|
||||
patternList: ['packetbeat-*'],
|
||||
},
|
||||
],
|
||||
sourcererScopes: {
|
||||
...mockGlobalState.sourcerer.sourcererScopes,
|
||||
[SourcererScopeName.timeline]: {
|
||||
...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline],
|
||||
loading: false,
|
||||
patternList,
|
||||
selectedDataViewId: null,
|
||||
selectedPatterns: ['myFakebeat-*'],
|
||||
missingPatterns: ['myFakebeat-*'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
(useSourcererDataView as jest.Mock).mockReturnValue({
|
||||
...sourcererDataView,
|
||||
activePatterns: ['myFakebeat-*'],
|
||||
});
|
||||
store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
|
||||
|
||||
render(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer scope={sourcererModel.SourcererScopeName.timeline} />
|
||||
</TestProviders>
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('Show UpdateDefaultDataViewModal CallOut', () => {
|
||||
fireEvent.click(screen.getByTestId('timeline-sourcerer-trigger'));
|
||||
fireEvent.click(screen.getByTestId('sourcerer-deprecated-update'));
|
||||
|
||||
expect(screen.getByTestId('sourcerer-deprecated-callout')).toHaveTextContent(
|
||||
'This timeline template uses a legacy data view selector'
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('sourcerer-deprecated-message')).toHaveTextContent(
|
||||
"We have preserved your timeline template by creating a temporary data view. If you'd like to modify your data, we can recreate your temporary data view with the new data view selector. You can also manually select a data view here."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Missing index patterns', () => {
|
||||
const { storage } = createSecuritySolutionStorageMock();
|
||||
const state2 = {
|
||||
...mockGlobalState,
|
||||
timeline: {
|
||||
...mockGlobalState.timeline,
|
||||
timelineById: {
|
||||
...mockGlobalState.timeline.timelineById,
|
||||
[TimelineId.active]: {
|
||||
...mockGlobalState.timeline.timelineById.test,
|
||||
timelineType: TimelineType.template,
|
||||
},
|
||||
},
|
||||
},
|
||||
sourcerer: {
|
||||
...mockGlobalState.sourcerer,
|
||||
kibanaDataViews: [
|
||||
mockGlobalState.sourcerer.defaultDataView,
|
||||
{
|
||||
...mockGlobalState.sourcerer.defaultDataView,
|
||||
id: '1234',
|
||||
title: 'auditbeat-*',
|
||||
patternList: ['auditbeat-*'],
|
||||
},
|
||||
{
|
||||
...mockGlobalState.sourcerer.defaultDataView,
|
||||
id: '12347',
|
||||
title: 'packetbeat-*',
|
||||
patternList: ['packetbeat-*'],
|
||||
},
|
||||
],
|
||||
sourcererScopes: {
|
||||
...mockGlobalState.sourcerer.sourcererScopes,
|
||||
[SourcererScopeName.timeline]: {
|
||||
...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline],
|
||||
loading: false,
|
||||
patternList,
|
||||
selectedDataViewId: 'fake-data-view-id',
|
||||
selectedPatterns: ['myFakebeat-*'],
|
||||
missingPatterns: ['myFakebeat-*'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
const pollForSignalIndexMock = jest.fn();
|
||||
(useSignalHelpers as jest.Mock).mockReturnValue({
|
||||
pollForSignalIndex: pollForSignalIndexMock,
|
||||
signalIndexNeedsInit: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('Show UpdateDefaultDataViewModal CallOut for timeline', async () => {
|
||||
(useSourcererDataView as jest.Mock).mockReturnValue({
|
||||
...sourcererDataView,
|
||||
activePatterns: ['myFakebeat-*'],
|
||||
});
|
||||
const state3 = cloneDeep(state2);
|
||||
state3.timeline.timelineById[TimelineId.active].timelineType = TimelineType.default;
|
||||
store = createStore(state3, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
|
||||
|
||||
render(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer scope={sourcererModel.SourcererScopeName.timeline} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('timeline-sourcerer-trigger'));
|
||||
|
||||
fireEvent.click(screen.getByTestId('sourcerer-deprecated-update'));
|
||||
|
||||
expect(screen.getByTestId('sourcerer-deprecated-callout').textContent).toBe(
|
||||
'This timeline is out of date with the Security Data View'
|
||||
);
|
||||
expect(screen.getByTestId('sourcerer-current-patterns-message').textContent).toBe(
|
||||
'The active index patterns in this timeline are: myFakebeat-*'
|
||||
);
|
||||
expect(screen.queryAllByTestId('sourcerer-missing-patterns-callout')[0].textContent).toBe(
|
||||
'Security Data View is missing the following index patterns: myFakebeat-*'
|
||||
);
|
||||
expect(screen.queryAllByTestId('sourcerer-missing-patterns-message')[0].textContent).toBe(
|
||||
"We have preserved your timeline by creating a temporary data view. If you'd like to modify your data, we can add the missing index patterns to the Security Data View. You can also manually select a data view here."
|
||||
);
|
||||
});
|
||||
|
||||
test('Show UpdateDefaultDataViewModal CallOut for timeline template', async () => {
|
||||
(useSourcererDataView as jest.Mock).mockReturnValue({
|
||||
...sourcererDataView,
|
||||
activePatterns: ['myFakebeat-*'],
|
||||
});
|
||||
store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
|
||||
|
||||
render(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer scope={sourcererModel.SourcererScopeName.timeline} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('timeline-sourcerer-trigger'));
|
||||
|
||||
fireEvent.click(screen.getByTestId('sourcerer-deprecated-update'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByTestId('sourcerer-deprecated-callout')[0].textContent).toBe(
|
||||
'This timeline template is out of date with the Security Data View'
|
||||
);
|
||||
|
||||
expect(screen.queryAllByTestId('sourcerer-current-patterns-message')[0].textContent).toBe(
|
||||
'The active index patterns in this timeline template are: myFakebeat-*'
|
||||
);
|
||||
|
||||
expect(screen.queryAllByTestId('sourcerer-missing-patterns-callout')[0].textContent).toBe(
|
||||
'Security Data View is missing the following index patterns: myFakebeat-*'
|
||||
);
|
||||
|
||||
expect(screen.queryAllByTestId('sourcerer-missing-patterns-message')[0].textContent).toBe(
|
||||
"We have preserved your timeline template by creating a temporary data view. If you'd like to modify your data, we can add the missing index patterns to the Security Data View. You can also manually select a data view here."
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sourcerer integration tests', () => {
|
||||
const state = {
|
||||
...mockGlobalState,
|
||||
sourcerer: {
|
||||
...mockGlobalState.sourcerer,
|
||||
kibanaDataViews: [
|
||||
mockGlobalState.sourcerer.defaultDataView,
|
||||
{
|
||||
...mockGlobalState.sourcerer.defaultDataView,
|
||||
id: '1234',
|
||||
title: 'fakebeat-*,neatbeat-*',
|
||||
patternList: ['fakebeat-*'],
|
||||
},
|
||||
],
|
||||
sourcererScopes: {
|
||||
...mockGlobalState.sourcerer.sourcererScopes,
|
||||
[SourcererScopeName.default]: {
|
||||
...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.default],
|
||||
loading: false,
|
||||
selectedDataViewId: id,
|
||||
selectedPatterns: patternListNoSignals.slice(0, 2),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { storage } = createSecuritySolutionStorageMock();
|
||||
|
||||
beforeEach(() => {
|
||||
const pollForSignalIndexMock = jest.fn();
|
||||
(useSignalHelpers as jest.Mock).mockReturnValue({
|
||||
pollForSignalIndex: pollForSignalIndexMock,
|
||||
signalIndexNeedsInit: false,
|
||||
});
|
||||
|
||||
(useSourcererDataView as jest.Mock).mockReturnValue({
|
||||
...sourcererDataView,
|
||||
activePatterns: ['myFakebeat-*'],
|
||||
});
|
||||
store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('Selects a different index pattern', async () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
wrapper.find(`[data-test-subj="sourcerer-trigger"]`).first().simulate('click');
|
||||
wrapper.find(`button[data-test-subj="sourcerer-select"]`).first().simulate('click');
|
||||
|
||||
wrapper.find(`[data-test-subj="dataView-option-super"]`).first().simulate('click');
|
||||
expect(checkOptionsAndSelections(wrapper, ['fakebeat-*'])).toEqual({
|
||||
availableOptionCount: 0,
|
||||
optionsSelected: true,
|
||||
});
|
||||
wrapper.find(`button[data-test-subj="sourcerer-save"]`).first().simulate('click');
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
sourcererActions.setSelectedDataView({
|
||||
id: SourcererScopeName.default,
|
||||
selectedDataViewId: '1234',
|
||||
selectedPatterns: ['fakebeat-*'],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,152 @@
|
|||
/*
|
||||
* 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 from 'react';
|
||||
import type { ReactWrapper } from 'enzyme';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
import { SourcererScopeName } from '../../store/sourcerer/model';
|
||||
import { Sourcerer } from '.';
|
||||
import { useSignalHelpers } from '../../containers/sourcerer/use_signal_helpers';
|
||||
import { sourcererActions, sourcererModel } from '../../store/sourcerer';
|
||||
import {
|
||||
createSecuritySolutionStorageMock,
|
||||
kibanaObservable,
|
||||
mockGlobalState,
|
||||
SUB_PLUGINS_REDUCER,
|
||||
TestProviders,
|
||||
} from '../../mock';
|
||||
import { createStore } from '../../store';
|
||||
import { sortWithExcludesAtEnd } from '../../../../common/utils/sourcerer';
|
||||
import { useSourcererDataView } from '../../containers/sourcerer';
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
|
||||
jest.mock('../../containers/sourcerer');
|
||||
jest.mock('../../containers/sourcerer/use_signal_helpers');
|
||||
const mockUseUpdateDataView = jest.fn().mockReturnValue(() => true);
|
||||
jest.mock('./use_update_data_view', () => ({
|
||||
useUpdateDataView: () => mockUseUpdateDataView,
|
||||
}));
|
||||
jest.mock('react-redux', () => {
|
||||
const original = jest.requireActual('react-redux');
|
||||
|
||||
return {
|
||||
...original,
|
||||
useDispatch: () => mockDispatch,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@kbn/kibana-react-plugin/public', () => {
|
||||
const original = jest.requireActual('@kbn/kibana-react-plugin/public');
|
||||
|
||||
return {
|
||||
...original,
|
||||
toMountPoint: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockUpdateUrlParam = jest.fn();
|
||||
jest.mock('../../utils/global_query_string', () => {
|
||||
const original = jest.requireActual('../../utils/global_query_string');
|
||||
|
||||
return {
|
||||
...original,
|
||||
useUpdateUrlParam: () => mockUpdateUrlParam,
|
||||
};
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
scope: sourcererModel.SourcererScopeName.default,
|
||||
};
|
||||
|
||||
const checkOptionsAndSelections = (wrapper: ReactWrapper, patterns: string[]) => ({
|
||||
availableOptionCount:
|
||||
wrapper.find('List').length > 0 ? wrapper.find('List').prop('itemCount') : 0,
|
||||
optionsSelected: patterns.every((pattern) =>
|
||||
wrapper.find(`[data-test-subj="sourcerer-combo-box"] span[title="${pattern}"]`).first().exists()
|
||||
),
|
||||
});
|
||||
|
||||
const { id, patternList } = mockGlobalState.sourcerer.defaultDataView;
|
||||
const patternListNoSignals = sortWithExcludesAtEnd(
|
||||
patternList.filter((p) => p !== mockGlobalState.sourcerer.signalIndexName)
|
||||
);
|
||||
let store: ReturnType<typeof createStore>;
|
||||
const sourcererDataView = {
|
||||
indicesExist: true,
|
||||
loading: false,
|
||||
};
|
||||
|
||||
describe('Sourcerer integration tests', () => {
|
||||
const state = {
|
||||
...mockGlobalState,
|
||||
sourcerer: {
|
||||
...mockGlobalState.sourcerer,
|
||||
kibanaDataViews: [
|
||||
mockGlobalState.sourcerer.defaultDataView,
|
||||
{
|
||||
...mockGlobalState.sourcerer.defaultDataView,
|
||||
id: '1234',
|
||||
title: 'fakebeat-*,neatbeat-*',
|
||||
patternList: ['fakebeat-*'],
|
||||
},
|
||||
],
|
||||
sourcererScopes: {
|
||||
...mockGlobalState.sourcerer.sourcererScopes,
|
||||
[SourcererScopeName.default]: {
|
||||
...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.default],
|
||||
loading: false,
|
||||
selectedDataViewId: id,
|
||||
selectedPatterns: patternListNoSignals.slice(0, 2),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { storage } = createSecuritySolutionStorageMock();
|
||||
|
||||
beforeEach(() => {
|
||||
const pollForSignalIndexMock = jest.fn();
|
||||
(useSignalHelpers as jest.Mock).mockReturnValue({
|
||||
pollForSignalIndex: pollForSignalIndexMock,
|
||||
signalIndexNeedsInit: false,
|
||||
});
|
||||
|
||||
(useSourcererDataView as jest.Mock).mockReturnValue({
|
||||
...sourcererDataView,
|
||||
activePatterns: ['myFakebeat-*'],
|
||||
});
|
||||
store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('Selects a different index pattern', async () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
wrapper.find(`[data-test-subj="sourcerer-trigger"]`).first().simulate('click');
|
||||
wrapper.find(`button[data-test-subj="sourcerer-select"]`).first().simulate('click');
|
||||
|
||||
wrapper.find(`[data-test-subj="dataView-option-super"]`).first().simulate('click');
|
||||
expect(checkOptionsAndSelections(wrapper, ['fakebeat-*'])).toEqual({
|
||||
availableOptionCount: 0,
|
||||
optionsSelected: true,
|
||||
});
|
||||
wrapper.find(`button[data-test-subj="sourcerer-save"]`).first().simulate('click');
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
sourcererActions.setSelectedDataView({
|
||||
id: SourcererScopeName.default,
|
||||
selectedDataViewId: '1234',
|
||||
selectedPatterns: ['fakebeat-*'],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
|
@ -107,6 +107,7 @@ export const TemporarySourcererComp = React.memo<Props>(
|
|||
const timelineType = useDeepEqualSelector(
|
||||
(state) => (getTimeline(state, TimelineId.active) ?? timelineDefaults).timelineType
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiCallOut
|
||||
|
@ -139,14 +140,15 @@ export const TemporarySourcererComp = React.memo<Props>(
|
|||
)}
|
||||
{isModified === 'missingPatterns' && (
|
||||
<>
|
||||
<FormattedMessage
|
||||
data-test-subj="sourcerer-missing-patterns-callout"
|
||||
id="xpack.securitySolution.indexPatterns.missingPatterns.callout"
|
||||
defaultMessage="Security Data View is missing the following index patterns: {callout}"
|
||||
values={{
|
||||
callout: <Blockquote>{missingPatterns.join(', ')}</Blockquote>,
|
||||
}}
|
||||
/>
|
||||
<span data-test-subj="sourcerer-missing-patterns-callout">
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.indexPatterns.missingPatterns.callout"
|
||||
defaultMessage="Security Data View is missing the following index patterns: {callout}"
|
||||
values={{
|
||||
callout: <Blockquote>{missingPatterns.join(', ')}</Blockquote>,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<MissingPatternsMessage timelineType={timelineType} onReset={onReset} />
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -0,0 +1,188 @@
|
|||
/*
|
||||
* 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 from 'react';
|
||||
|
||||
import { render, cleanup, fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { SourcererScopeName } from '../../store/sourcerer/model';
|
||||
import { Sourcerer } from '.';
|
||||
import { sourcererModel } from '../../store/sourcerer';
|
||||
import {
|
||||
createSecuritySolutionStorageMock,
|
||||
kibanaObservable,
|
||||
mockGlobalState,
|
||||
SUB_PLUGINS_REDUCER,
|
||||
TestProviders,
|
||||
} from '../../mock';
|
||||
import { createStore } from '../../store';
|
||||
import { useSourcererDataView } from '../../containers/sourcerer';
|
||||
import { useSignalHelpers } from '../../containers/sourcerer/use_signal_helpers';
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
|
||||
jest.mock('../../containers/sourcerer');
|
||||
jest.mock('../../containers/sourcerer/use_signal_helpers');
|
||||
const mockUseUpdateDataView = jest.fn().mockReturnValue(() => true);
|
||||
jest.mock('./use_update_data_view', () => ({
|
||||
useUpdateDataView: () => mockUseUpdateDataView,
|
||||
}));
|
||||
jest.mock('react-redux', () => {
|
||||
const original = jest.requireActual('react-redux');
|
||||
|
||||
return {
|
||||
...original,
|
||||
useDispatch: () => mockDispatch,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@kbn/kibana-react-plugin/public', () => {
|
||||
const original = jest.requireActual('@kbn/kibana-react-plugin/public');
|
||||
|
||||
return {
|
||||
...original,
|
||||
toMountPoint: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockUpdateUrlParam = jest.fn();
|
||||
jest.mock('../../utils/global_query_string', () => {
|
||||
const original = jest.requireActual('../../utils/global_query_string');
|
||||
|
||||
return {
|
||||
...original,
|
||||
useUpdateUrlParam: () => mockUpdateUrlParam,
|
||||
};
|
||||
});
|
||||
|
||||
const { id } = mockGlobalState.sourcerer.defaultDataView;
|
||||
|
||||
let store: ReturnType<typeof createStore>;
|
||||
const sourcererDataView = {
|
||||
indicesExist: true,
|
||||
loading: false,
|
||||
};
|
||||
|
||||
describe('timeline sourcerer', () => {
|
||||
const { storage } = createSecuritySolutionStorageMock();
|
||||
store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
|
||||
const testProps = {
|
||||
scope: sourcererModel.SourcererScopeName.timeline,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const pollForSignalIndexMock = jest.fn();
|
||||
|
||||
(useSourcererDataView as jest.Mock).mockReturnValue(sourcererDataView);
|
||||
|
||||
(useSignalHelpers as jest.Mock).mockReturnValue({
|
||||
pollForSignalIndex: pollForSignalIndexMock,
|
||||
signalIndexNeedsInit: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer {...testProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('timeline-sourcerer-trigger'));
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(screen.getByTestId(`sourcerer-advanced-options-toggle`));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders "alerts only" checkbox, unchecked', async () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('sourcerer-alert-only-checkbox').parentElement).toHaveTextContent(
|
||||
'Show only detection alerts'
|
||||
);
|
||||
expect(screen.getByTestId('sourcerer-alert-only-checkbox')).not.toBeChecked();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('sourcerer-alert-only-checkbox'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('sourcerer-alert-only-checkbox')).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
it('data view selector is enabled', async () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('sourcerer-select')).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('data view selector is default to Security Default Data View', async () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('security-option-super')).toHaveTextContent(
|
||||
'Security Default Data View'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('index pattern selector is enabled', async () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('sourcerer-combo-box')).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('render reset button', async () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('sourcerer-reset')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
it('render save button', async () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('sourcerer-save')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
it('Checks box when only alerts index is selected in timeline', async () => {
|
||||
cleanup();
|
||||
const state2 = {
|
||||
...mockGlobalState,
|
||||
sourcerer: {
|
||||
...mockGlobalState.sourcerer,
|
||||
sourcererScopes: {
|
||||
...mockGlobalState.sourcerer.sourcererScopes,
|
||||
[SourcererScopeName.timeline]: {
|
||||
...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline],
|
||||
loading: false,
|
||||
selectedDataViewId: id,
|
||||
selectedPatterns: [`${mockGlobalState.sourcerer.signalIndexName}`],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
store = createStore(
|
||||
state2,
|
||||
SUB_PLUGINS_REDUCER,
|
||||
|
||||
kibanaObservable,
|
||||
storage
|
||||
);
|
||||
|
||||
render(
|
||||
<TestProviders store={store}>
|
||||
<Sourcerer scope={sourcererModel.SourcererScopeName.timeline} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.queryAllByTestId('timeline-sourcerer-trigger')[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('sourcerer-alert-only-checkbox')).toBeChecked();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -7,9 +7,9 @@
|
|||
|
||||
import type { FC } from 'react';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { EuiToolTip } from '@elastic/eui';
|
||||
import { EuiToolTip, EuiButton } from '@elastic/eui';
|
||||
import * as i18n from './translations';
|
||||
import { getTooltipContent, StyledBadge, StyledButton } from './helpers';
|
||||
import { getTooltipContent, StyledBadge, StyledButtonEmpty } from './helpers';
|
||||
import type { ModifiedTypes } from './use_pick_index_patterns';
|
||||
|
||||
interface Props {
|
||||
|
@ -68,12 +68,17 @@ export const TriggerComponent: FC<Props> = ({
|
|||
}
|
||||
}, [isModified]);
|
||||
|
||||
const Button = useMemo(
|
||||
() => (isTimelineSourcerer ? EuiButton : StyledButtonEmpty),
|
||||
[isTimelineSourcerer]
|
||||
);
|
||||
|
||||
const trigger = useMemo(
|
||||
() => (
|
||||
<StyledButton
|
||||
<Button
|
||||
aria-label={i18n.DATA_VIEW}
|
||||
data-test-subj={isTimelineSourcerer ? 'timeline-sourcerer-trigger' : 'sourcerer-trigger'}
|
||||
flush="left"
|
||||
color="primary"
|
||||
iconSide="right"
|
||||
iconType="arrowDown"
|
||||
disabled={disabled}
|
||||
|
@ -83,9 +88,9 @@ export const TriggerComponent: FC<Props> = ({
|
|||
>
|
||||
{i18n.DATA_VIEW}
|
||||
{!disabled && badge}
|
||||
</StyledButton>
|
||||
</Button>
|
||||
),
|
||||
[disabled, badge, isTimelineSourcerer, loading, onClick]
|
||||
[disabled, badge, isTimelineSourcerer, loading, onClick, Button]
|
||||
);
|
||||
|
||||
const tooltipContent = useMemo(
|
||||
|
|
|
@ -43,28 +43,30 @@ export const CurrentPatternsMessage = ({
|
|||
|
||||
if (timelineType === TimelineType.template) {
|
||||
return (
|
||||
<span data-test-subj="sourcerer-current-patterns-message">
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.indexPatterns.timelineTemplate.currentPatterns"
|
||||
defaultMessage="The active index patterns in this timeline template are{tooltip}: {callout}"
|
||||
values={{
|
||||
tooltip,
|
||||
callout: <Blockquote>{activePatterns.join(', ')}</Blockquote>,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span data-test-subj="sourcerer-current-patterns-message">
|
||||
<FormattedMessage
|
||||
data-test-subj="sourcerer-current-patterns-message"
|
||||
id="xpack.securitySolution.indexPatterns.timelineTemplate.currentPatterns"
|
||||
defaultMessage="The active index patterns in this timeline template are{tooltip}: {callout}"
|
||||
id="xpack.securitySolution.indexPatterns.timeline.currentPatterns"
|
||||
defaultMessage="The active index patterns in this timeline are{tooltip}: {callout}"
|
||||
values={{
|
||||
tooltip,
|
||||
callout: <Blockquote>{activePatterns.join(', ')}</Blockquote>,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormattedMessage
|
||||
data-test-subj="sourcerer-current-patterns-message"
|
||||
id="xpack.securitySolution.indexPatterns.timeline.currentPatterns"
|
||||
defaultMessage="The active index patterns in this timeline are{tooltip}: {callout}"
|
||||
values={{
|
||||
tooltip,
|
||||
callout: <Blockquote>{activePatterns.join(', ')}</Blockquote>,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -147,25 +149,27 @@ export const DeprecatedMessage = ({
|
|||
}) => {
|
||||
if (timelineType === TimelineType.template) {
|
||||
return (
|
||||
<span data-test-subj="sourcerer-deprecated-message">
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.indexPatterns.timelineTemplate.toggleToNewSourcerer"
|
||||
defaultMessage="We have preserved your timeline template by creating a temporary data view. If you'd like to modify your data, we can recreate your temporary data view with the new data view selector. You can also manually select a data view {link}."
|
||||
values={{
|
||||
link: <EuiLink onClick={onReset}>{i18n.TOGGLE_TO_NEW_SOURCERER}</EuiLink>,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span data-test-subj="sourcerer-deprecated-message">
|
||||
<FormattedMessage
|
||||
data-test-subj="sourcerer-deprecated-message"
|
||||
id="xpack.securitySolution.indexPatterns.timelineTemplate.toggleToNewSourcerer"
|
||||
defaultMessage="We have preserved your timeline template by creating a temporary data view. If you'd like to modify your data, we can recreate your temporary data view with the new data view selector. You can also manually select a data view {link}."
|
||||
id="xpack.securitySolution.indexPatterns.timeline.toggleToNewSourcerer"
|
||||
defaultMessage="We have preserved your timeline by creating a temporary data view. If you'd like to modify your data, we can recreate your temporary data view with the new data view selector. You can also manually select a data view {link}."
|
||||
values={{
|
||||
link: <EuiLink onClick={onReset}>{i18n.TOGGLE_TO_NEW_SOURCERER}</EuiLink>,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FormattedMessage
|
||||
data-test-subj="sourcerer-deprecated-message"
|
||||
id="xpack.securitySolution.indexPatterns.timeline.toggleToNewSourcerer"
|
||||
defaultMessage="We have preserved your timeline by creating a temporary data view. If you'd like to modify your data, we can recreate your temporary data view with the new data view selector. You can also manually select a data view {link}."
|
||||
values={{
|
||||
link: <EuiLink onClick={onReset}>{i18n.TOGGLE_TO_NEW_SOURCERER}</EuiLink>,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -178,24 +182,26 @@ export const MissingPatternsMessage = ({
|
|||
}) => {
|
||||
if (timelineType === TimelineType.template) {
|
||||
return (
|
||||
<span data-test-subj="sourcerer-missing-patterns-message">
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.indexPatterns.missingPatterns.timelineTemplate.description"
|
||||
defaultMessage="We have preserved your timeline template by creating a temporary data view. If you'd like to modify your data, we can add the missing index patterns to the Security Data View. You can also manually select a data view {link}."
|
||||
values={{
|
||||
link: <EuiLink onClick={onReset}>{i18n.TOGGLE_TO_NEW_SOURCERER}</EuiLink>,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span data-test-subj="sourcerer-missing-patterns-message">
|
||||
<FormattedMessage
|
||||
data-test-subj="sourcerer-missing-patterns-message"
|
||||
id="xpack.securitySolution.indexPatterns.missingPatterns.timelineTemplate.description"
|
||||
defaultMessage="We have preserved your timeline template by creating a temporary data view. If you'd like to modify your data, we can add the missing index patterns to the Security Data View. You can also manually select a data view {link}."
|
||||
id="xpack.securitySolution.indexPatterns.missingPatterns.timeline.description"
|
||||
defaultMessage="We have preserved your timeline by creating a temporary data view. If you'd like to modify your data, we can add the missing index patterns to the Security Data View. You can also manually select a data view {link}."
|
||||
values={{
|
||||
link: <EuiLink onClick={onReset}>{i18n.TOGGLE_TO_NEW_SOURCERER}</EuiLink>,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FormattedMessage
|
||||
data-test-subj="sourcerer-missing-patterns-message"
|
||||
id="xpack.securitySolution.indexPatterns.missingPatterns.timeline.description"
|
||||
defaultMessage="We have preserved your timeline by creating a temporary data view. If you'd like to modify your data, we can add the missing index patterns to the Security Data View. You can also manually select a data view {link}."
|
||||
values={{
|
||||
link: <EuiLink onClick={onReset}>{i18n.TOGGLE_TO_NEW_SOURCERER}</EuiLink>,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -233,7 +233,7 @@ export const convertToBuildEsQuery = ({
|
|||
|
||||
export const combineQueries = ({
|
||||
config,
|
||||
dataProviders,
|
||||
dataProviders = [],
|
||||
indexPattern,
|
||||
browserFields,
|
||||
filters = [],
|
||||
|
|
|
@ -372,6 +372,7 @@ export const mockGlobalState: State = {
|
|||
itemsPerPageOptions: [10, 25, 50, 100],
|
||||
savedSearchId: null,
|
||||
isDiscoverSavedSearchLoaded: false,
|
||||
isDataProviderVisible: true,
|
||||
},
|
||||
},
|
||||
insertTimeline: null,
|
||||
|
|
|
@ -2027,6 +2027,7 @@ export const mockTimelineModel: TimelineModel = {
|
|||
templateTimelineVersion: null,
|
||||
version: '1',
|
||||
savedSearchId: null,
|
||||
isDataProviderVisible: false,
|
||||
};
|
||||
|
||||
export const mockDataTableModel: DataTableModel = {
|
||||
|
@ -2208,6 +2209,7 @@ export const defaultTimelineProps: CreateTimelineProps = {
|
|||
version: null,
|
||||
savedSearchId: null,
|
||||
isDiscoverSavedSearchLoaded: false,
|
||||
isDataProviderVisible: false,
|
||||
},
|
||||
to: '2018-11-05T19:03:25.937Z',
|
||||
notes: null,
|
||||
|
|
|
@ -453,9 +453,9 @@ describe('alert actions', () => {
|
|||
version: null,
|
||||
savedSearchId: null,
|
||||
isDiscoverSavedSearchLoaded: false,
|
||||
isDataProviderVisible: false,
|
||||
},
|
||||
to: '2018-11-05T19:03:25.937Z',
|
||||
resolveTimelineConfig: undefined,
|
||||
ruleNote: '# this is some markdown documentation',
|
||||
ruleAuthor: ['elastic'],
|
||||
};
|
||||
|
|
|
@ -30,10 +30,32 @@ import { useKibana } from '../../../../common/lib/kibana';
|
|||
|
||||
const TextArea = styled(EuiTextArea)`
|
||||
display: block;
|
||||
border: ${({ theme }) => theme.eui.euiBorderThin};
|
||||
border-bottom: 0;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
border-radius: 0px;
|
||||
min-height: ${({ theme }) => theme.eui.euiFormControlHeight};
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledFormRow = styled(EuiFormRow)`
|
||||
border: ${({ theme }) => theme.eui.euiBorderThin};
|
||||
border-radius: ${({ theme }) => theme.eui.euiBorderRadius};
|
||||
|
||||
.euiFormRow__labelWrapper {
|
||||
background: ${({ theme }) => theme.eui.euiColorLightestShade};
|
||||
border-top-left-radius: ${({ theme }) => theme.eui.euiBorderRadius};
|
||||
border-top-right-radius: ${({ theme }) => theme.eui.euiBorderRadius};
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 0px;
|
||||
label {
|
||||
color: ${({ theme }) => theme.eui.euiTextSubduedColor};
|
||||
&.euiFormLabel-isInvalid {
|
||||
color: ${({ theme }) => theme.eui.euiColorDangerText};
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export interface FieldValueQueryBar {
|
||||
|
@ -157,7 +179,7 @@ export const EqlQueryBar: FC<EqlQueryBarProps> = ({
|
|||
);
|
||||
|
||||
return (
|
||||
<EuiFormRow
|
||||
<StyledFormRow
|
||||
label={field.label}
|
||||
labelAppend={field.labelAppend}
|
||||
helpText={field.helpText}
|
||||
|
@ -199,6 +221,6 @@ export const EqlQueryBar: FC<EqlQueryBarProps> = ({
|
|||
</>
|
||||
)}
|
||||
</>
|
||||
</EuiFormRow>
|
||||
</StyledFormRow>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -14,7 +14,6 @@ import {
|
|||
EuiFlexItem,
|
||||
EuiFormRow,
|
||||
EuiLoadingSpinner,
|
||||
EuiPanel,
|
||||
EuiPopover,
|
||||
EuiPopoverTitle,
|
||||
} from '@elastic/eui';
|
||||
|
@ -44,9 +43,11 @@ export interface Props {
|
|||
|
||||
type SizeVoidFunc = (newSize: string) => void;
|
||||
|
||||
const Container = styled(EuiPanel)`
|
||||
const Container = styled(EuiFlexGroup)`
|
||||
border-radius: 0;
|
||||
background: ${({ theme }) => theme.eui.euiPageBackgroundColor};
|
||||
border-bottom-left-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
background: ${({ theme }) => theme.eui.euiColorLightestShade};
|
||||
padding: ${({ theme }) => theme.eui.euiSizeXS} ${({ theme }) => theme.eui.euiSizeS};
|
||||
`;
|
||||
|
||||
|
@ -161,96 +162,113 @@ export const EqlQueryBarFooter: FC<Props> = ({
|
|||
|
||||
return (
|
||||
<Container>
|
||||
<FlexGroup alignItems="center" justifyContent="spaceBetween" gutterSize="none">
|
||||
<EuiFlexItem>
|
||||
{errors.length > 0 && (
|
||||
<ErrorsPopover ariaLabel={i18n.EQL_VALIDATION_ERROR_POPOVER_LABEL} errors={errors} />
|
||||
)}
|
||||
{isLoading && <Spinner data-test-subj="eql-validation-loading" size="m" />}
|
||||
<FlexGroup
|
||||
alignItems="center"
|
||||
justifyContent="spaceBetween"
|
||||
gutterSize="none"
|
||||
responsive={false}
|
||||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={false}>
|
||||
{errors.length > 0 && (
|
||||
<ErrorsPopover
|
||||
ariaLabel={i18n.EQL_VALIDATION_ERROR_POPOVER_LABEL}
|
||||
errors={errors}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <Spinner data-test-subj="eql-validation-loading" size="m" />}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
{!onOptionsChange && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EqlOverviewLink />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
{onOptionsChange && (
|
||||
<>
|
||||
<FlexItemWithMarginRight grow={false}>
|
||||
<EqlOverviewLink />
|
||||
</FlexItemWithMarginRight>
|
||||
<FlexItemLeftBorder grow={false}>
|
||||
<EuiPopover
|
||||
button={
|
||||
<EuiButtonIcon
|
||||
onClick={openEqlSettingsHandler}
|
||||
iconType="controlsVertical"
|
||||
isDisabled={openEqlSettings}
|
||||
aria-label="eql settings"
|
||||
data-test-subj="eql-settings-trigger"
|
||||
/>
|
||||
}
|
||||
isOpen={openEqlSettings}
|
||||
closePopover={closeEqlSettingsHandler}
|
||||
anchorPosition="downCenter"
|
||||
ownFocus={false}
|
||||
>
|
||||
<EuiPopoverTitle>{i18n.EQL_SETTINGS_TITLE}</EuiPopoverTitle>
|
||||
<div style={{ width: '300px' }}>
|
||||
{!isSizeOptionDisabled && (
|
||||
<EuiFormRow
|
||||
data-test-subj="eql-size-field"
|
||||
label={i18n.EQL_OPTIONS_SIZE_LABEL}
|
||||
helpText={i18n.EQL_OPTIONS_SIZE_HELPER}
|
||||
>
|
||||
<EuiFieldNumber
|
||||
value={localSize}
|
||||
onChange={handleSizeField}
|
||||
min={1}
|
||||
max={10000}
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup gutterSize={'none'} alignItems="center" responsive={false}>
|
||||
{!onOptionsChange && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EqlOverviewLink />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
|
||||
{onOptionsChange && (
|
||||
<>
|
||||
<FlexItemWithMarginRight grow={false}>
|
||||
<EqlOverviewLink />
|
||||
</FlexItemWithMarginRight>
|
||||
<FlexItemLeftBorder grow={false}>
|
||||
<EuiPopover
|
||||
button={
|
||||
<EuiButtonIcon
|
||||
onClick={openEqlSettingsHandler}
|
||||
iconType="controlsVertical"
|
||||
isDisabled={openEqlSettings}
|
||||
aria-label="eql settings"
|
||||
data-test-subj="eql-settings-trigger"
|
||||
/>
|
||||
</EuiFormRow>
|
||||
)}
|
||||
<EuiFormRow
|
||||
data-test-subj="eql-event-category-field"
|
||||
label={i18n.EQL_OPTIONS_EVENT_CATEGORY_FIELD_LABEL}
|
||||
helpText={i18n.EQL_OPTIONS_EVENT_CATEGORY_FIELD_HELPER}
|
||||
}
|
||||
isOpen={openEqlSettings}
|
||||
closePopover={closeEqlSettingsHandler}
|
||||
anchorPosition="downCenter"
|
||||
ownFocus={false}
|
||||
>
|
||||
<EuiComboBox
|
||||
options={optionsData?.keywordFields}
|
||||
selectedOptions={eventCategoryField}
|
||||
singleSelection={singleSelection}
|
||||
onChange={handleEventCategoryField}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
data-test-subj="eql-tiebreaker-field"
|
||||
label={i18n.EQL_OPTIONS_EVENT_TIEBREAKER_FIELD_LABEL}
|
||||
helpText={i18n.EQL_OPTIONS_EVENT_TIEBREAKER_FIELD_HELPER}
|
||||
>
|
||||
<EuiComboBox
|
||||
options={optionsData?.nonDateFields}
|
||||
selectedOptions={tiebreakerField}
|
||||
singleSelection={singleSelection}
|
||||
onChange={handleTiebreakerField}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
data-test-subj="eql-timestamp-field"
|
||||
label={i18n.EQL_OPTIONS_EVENT_TIMESTAMP_FIELD_LABEL}
|
||||
helpText={i18n.EQL_OPTIONS_EVENT_TIMESTAMP_FIELD_HELPER}
|
||||
>
|
||||
<EuiComboBox
|
||||
options={optionsData?.dateFields}
|
||||
selectedOptions={timestampField}
|
||||
singleSelection={singleSelection}
|
||||
onChange={handleTimestampField}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</div>
|
||||
</EuiPopover>
|
||||
</FlexItemLeftBorder>
|
||||
</>
|
||||
)}
|
||||
<EuiPopoverTitle>{i18n.EQL_SETTINGS_TITLE}</EuiPopoverTitle>
|
||||
<div style={{ width: '300px' }}>
|
||||
{!isSizeOptionDisabled && (
|
||||
<EuiFormRow
|
||||
data-test-subj="eql-size-field"
|
||||
label={i18n.EQL_OPTIONS_SIZE_LABEL}
|
||||
helpText={i18n.EQL_OPTIONS_SIZE_HELPER}
|
||||
>
|
||||
<EuiFieldNumber
|
||||
value={localSize}
|
||||
onChange={handleSizeField}
|
||||
min={1}
|
||||
max={10000}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
)}
|
||||
<EuiFormRow
|
||||
data-test-subj="eql-event-category-field"
|
||||
label={i18n.EQL_OPTIONS_EVENT_CATEGORY_FIELD_LABEL}
|
||||
helpText={i18n.EQL_OPTIONS_EVENT_CATEGORY_FIELD_HELPER}
|
||||
>
|
||||
<EuiComboBox
|
||||
options={optionsData?.keywordFields}
|
||||
selectedOptions={eventCategoryField}
|
||||
singleSelection={singleSelection}
|
||||
onChange={handleEventCategoryField}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
data-test-subj="eql-tiebreaker-field"
|
||||
label={i18n.EQL_OPTIONS_EVENT_TIEBREAKER_FIELD_LABEL}
|
||||
helpText={i18n.EQL_OPTIONS_EVENT_TIEBREAKER_FIELD_HELPER}
|
||||
>
|
||||
<EuiComboBox
|
||||
options={optionsData?.nonDateFields}
|
||||
selectedOptions={tiebreakerField}
|
||||
singleSelection={singleSelection}
|
||||
onChange={handleTiebreakerField}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
data-test-subj="eql-timestamp-field"
|
||||
label={i18n.EQL_OPTIONS_EVENT_TIMESTAMP_FIELD_LABEL}
|
||||
helpText={i18n.EQL_OPTIONS_EVENT_TIMESTAMP_FIELD_HELPER}
|
||||
>
|
||||
<EuiComboBox
|
||||
options={optionsData?.dateFields}
|
||||
selectedOptions={timestampField}
|
||||
singleSelection={singleSelection}
|
||||
onChange={handleTimestampField}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</div>
|
||||
</EuiPopover>
|
||||
</FlexItemLeftBorder>
|
||||
</>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</FlexGroup>
|
||||
</Container>
|
||||
);
|
||||
|
|
|
@ -105,7 +105,7 @@ exports[`Authentication Host Table Component rendering it renders the host authe
|
|||
class="euiFlexItem emotion-euiFlexItem-grow-1"
|
||||
>
|
||||
<h4
|
||||
class="euiTitle emotion-euiTitle-m"
|
||||
class="euiTitle emotion-euiTitle-l"
|
||||
data-test-subj="header-section-title"
|
||||
>
|
||||
<span
|
||||
|
|
|
@ -105,7 +105,7 @@ exports[`Authentication User Table Component rendering it renders the user authe
|
|||
class="euiFlexItem emotion-euiFlexItem-grow-1"
|
||||
>
|
||||
<h4
|
||||
class="euiTitle emotion-euiTitle-m"
|
||||
class="euiTitle emotion-euiTitle-l"
|
||||
data-test-subj="header-section-title"
|
||||
>
|
||||
<span
|
||||
|
|
|
@ -9,22 +9,39 @@ exports[`Flyout rendering it renders correctly against snapshot 1`] = `
|
|||
data-test-subj="flyout-pane"
|
||||
/>
|
||||
</div>
|
||||
.c2 {
|
||||
display: block;
|
||||
.c3:active,
|
||||
.c3:focus {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.c1 > span {
|
||||
.c3 > span {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.c3 {
|
||||
.c4 {
|
||||
overflow: hidden;
|
||||
display: inline-block;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.c5 {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.c2 {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.c0 {
|
||||
overflow: hidden;
|
||||
backgroundColor: #1d1e24;
|
||||
color: #dfe5ef;
|
||||
padding-inline: 12px;
|
||||
border-radius: 0px;
|
||||
}
|
||||
|
||||
<div
|
||||
|
@ -32,100 +49,93 @@ exports[`Flyout rendering it renders correctly against snapshot 1`] = `
|
|||
data-test-subj="flyoutBottomBar"
|
||||
>
|
||||
<div
|
||||
class="euiPanel euiPanel--plain euiPanel--paddingSmall emotion-euiPanel-none-s-plain"
|
||||
class="euiPanel euiPanel--plain euiPanel--paddingSmall c0 emotion-euiPanel-m-s-plain"
|
||||
data-show="false"
|
||||
data-test-subj="timeline-flyout-header-panel"
|
||||
style="background-color: rgb(255, 255, 255); color: rgb(52, 55, 65);"
|
||||
>
|
||||
<div
|
||||
class="euiFlexGroup emotion-euiFlexGroup-s-flexStart-center-row"
|
||||
class="euiFlexGroup c1 eui-scrollBar emotion-euiFlexGroup-s-spaceBetween-center-row"
|
||||
>
|
||||
<div
|
||||
class="euiFlexItem emotion-euiFlexItem-growZero"
|
||||
>
|
||||
<div
|
||||
class="euiPopover emotion-euiPopover"
|
||||
id="timelineSettingsPopover"
|
||||
class="euiFlexGroup emotion-euiFlexGroup-xs-flexStart-center-row"
|
||||
>
|
||||
<div
|
||||
class="euiPopover__anchor css-16vtueo-render"
|
||||
class="euiFlexItem emotion-euiFlexItem-growZero"
|
||||
>
|
||||
<div
|
||||
class="euiFlexItem c2 emotion-euiFlexItem-growZero"
|
||||
>
|
||||
<button
|
||||
aria-label="Open timeline Untitled timeline"
|
||||
aria-pressed="false"
|
||||
class="euiButtonEmpty c3 active-timeline-button emotion-euiButtonDisplay-euiButtonEmpty-s-empty-primary-flush-both"
|
||||
data-test-subj="flyoutOverlay"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="euiButtonEmpty__content emotion-euiButtonDisplayContent"
|
||||
>
|
||||
<span
|
||||
class="eui-textTruncate euiButtonEmpty__text"
|
||||
>
|
||||
<div
|
||||
class="euiFlexGroup emotion-euiFlexGroup-none-flexStart-center-row"
|
||||
>
|
||||
<div
|
||||
class="euiFlexItem c4 emotion-euiFlexItem-growZero"
|
||||
data-test-subj="timeline-title"
|
||||
>
|
||||
Untitled timeline
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem emotion-euiFlexItem-growZero"
|
||||
>
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem emotion-euiFlexItem-growZero"
|
||||
>
|
||||
<div
|
||||
class="euiText c5 emotion-euiText-xs"
|
||||
data-test-subj="timeline-status"
|
||||
>
|
||||
<span
|
||||
class="emotion-euiTextColor-warning"
|
||||
>
|
||||
Unsaved
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem emotion-euiFlexItem-growZero"
|
||||
>
|
||||
<button
|
||||
aria-label="Add new timeline or template"
|
||||
class="euiButtonIcon add-timeline-button emotion-euiButtonIcon-m-empty-primary"
|
||||
data-test-subj="settings-plus-in-circle"
|
||||
aria-label="Add to favorites"
|
||||
aria-pressed="false"
|
||||
class="euiButtonIcon emotion-euiButtonIcon-xs-empty-primary"
|
||||
data-test-subj="timeline-favorite-empty-star"
|
||||
title="Add to favorites"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="euiButtonIcon__icon"
|
||||
color="inherit"
|
||||
data-euiicon-type="plusInCircle"
|
||||
data-euiicon-type="starEmpty"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem c0 emotion-euiFlexItem-growZero"
|
||||
>
|
||||
<button
|
||||
aria-label="Open timeline Untitled timeline"
|
||||
aria-pressed="false"
|
||||
class="euiButtonEmpty c1 active-timeline-button emotion-euiButtonDisplay-euiButtonEmpty-s-empty-primary-flush-both"
|
||||
data-test-subj="flyoutOverlay"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="euiButtonEmpty__content emotion-euiButtonDisplayContent"
|
||||
>
|
||||
<span
|
||||
class="eui-textTruncate euiButtonEmpty__text"
|
||||
>
|
||||
<div
|
||||
class="euiFlexGroup emotion-euiFlexGroup-none-flexStart-center-row"
|
||||
>
|
||||
<div
|
||||
class="euiFlexItem emotion-euiFlexItem-growZero"
|
||||
>
|
||||
<span
|
||||
class="euiToolTipAnchor emotion-euiToolTipAnchor-inlineBlock"
|
||||
>
|
||||
<div
|
||||
class="euiHealth c2 emotion-euiHealth-s"
|
||||
>
|
||||
<div
|
||||
class="euiFlexGroup emotion-euiFlexGroup-xs-flexStart-center-row"
|
||||
>
|
||||
<div
|
||||
class="euiFlexItem emotion-euiFlexItem-growZero"
|
||||
>
|
||||
<span
|
||||
color="warning"
|
||||
data-euiicon-type="dot"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem emotion-euiFlexItem-growZero"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem c3 emotion-euiFlexItem-growZero"
|
||||
>
|
||||
Untitled timeline
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem emotion-euiFlexItem-growZero"
|
||||
>
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* 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 from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { useKibana, useGetUserCasesPermissions } from '../../../../common/lib/kibana';
|
||||
import { TestProviders, mockIndexNames, mockIndexPattern } from '../../../../common/mock';
|
||||
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
|
||||
import { allCasesPermissions, readCasesPermissions } from '../../../../cases_test_utils';
|
||||
import { mockBrowserFields } from '../../../../common/containers/source/mock';
|
||||
import { TimelineActionMenu } from '.';
|
||||
import { TimelineId, TimelineTabs } from '../../../../../common/types';
|
||||
|
||||
const mockUseSourcererDataView: jest.Mock = useSourcererDataView as jest.Mock;
|
||||
jest.mock('../../../../common/containers/sourcerer');
|
||||
|
||||
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
jest.mock('@kbn/i18n-react', () => {
|
||||
const originalModule = jest.requireActual('@kbn/i18n-react');
|
||||
const FormattedRelative = jest.fn().mockImplementation(() => '20 hours ago');
|
||||
|
||||
return {
|
||||
...originalModule,
|
||||
FormattedRelative,
|
||||
};
|
||||
});
|
||||
|
||||
const sourcererDefaultValue = {
|
||||
sourcererDefaultValue: mockBrowserFields,
|
||||
indexPattern: mockIndexPattern,
|
||||
loading: false,
|
||||
selectedPatterns: mockIndexNames,
|
||||
};
|
||||
|
||||
describe('Action menu', () => {
|
||||
beforeEach(() => {
|
||||
// Mocking these services is required for the header component to render.
|
||||
mockUseSourcererDataView.mockImplementation(() => sourcererDefaultValue);
|
||||
useKibanaMock().services.application.capabilities = {
|
||||
navLinks: {},
|
||||
management: {},
|
||||
catalogue: {},
|
||||
actions: { show: true, crud: true },
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe('AddToCaseButton', () => {
|
||||
it('renders the button when the user has create and read permissions', () => {
|
||||
(useGetUserCasesPermissions as jest.Mock).mockReturnValue(allCasesPermissions());
|
||||
|
||||
render(
|
||||
<TestProviders>
|
||||
<TimelineActionMenu
|
||||
timelineId={TimelineId.test}
|
||||
activeTab={TimelineTabs.query}
|
||||
isInspectButtonDisabled={false}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('attach-timeline-case-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the button when the user does not have create permissions', () => {
|
||||
(useGetUserCasesPermissions as jest.Mock).mockReturnValue(readCasesPermissions());
|
||||
|
||||
render(
|
||||
<TestProviders>
|
||||
<TimelineActionMenu
|
||||
timelineId={TimelineId.test}
|
||||
activeTab={TimelineTabs.query}
|
||||
isInspectButtonDisabled={false}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('attach-timeline-case-button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* 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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import { useGetUserCasesPermissions } from '../../../../common/lib/kibana';
|
||||
import type { TimelineTabs } from '../../../../../common/types';
|
||||
import { InspectButton } from '../../../../common/components/inspect';
|
||||
import { InputsModelId } from '../../../../common/store/inputs/constants';
|
||||
import { AddToCaseButton } from '../add_to_case_button';
|
||||
import { NewTimelineAction } from './new_timeline';
|
||||
import { SaveTimelineButton } from './save_timeline_button';
|
||||
import { OpenTimelineAction } from './open_timeline';
|
||||
|
||||
interface TimelineActionMenuProps {
|
||||
mode?: 'compact' | 'normal';
|
||||
timelineId: string;
|
||||
isInspectButtonDisabled: boolean;
|
||||
activeTab: TimelineTabs;
|
||||
}
|
||||
|
||||
const TimelineActionMenuComponent = ({
|
||||
mode = 'normal',
|
||||
timelineId,
|
||||
activeTab,
|
||||
isInspectButtonDisabled,
|
||||
}: TimelineActionMenuProps) => {
|
||||
const userCasesPermissions = useGetUserCasesPermissions();
|
||||
return (
|
||||
<EuiFlexGroup
|
||||
gutterSize="xs"
|
||||
justifyContent="flexEnd"
|
||||
alignItems="center"
|
||||
responsive={false}
|
||||
data-test-subj="timeline-action-menu"
|
||||
>
|
||||
<EuiFlexItem data-test-subj="new-timeline-action">
|
||||
<NewTimelineAction timelineId={timelineId} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem data-test-subj="open-timeline-action">
|
||||
<OpenTimelineAction />
|
||||
</EuiFlexItem>
|
||||
{userCasesPermissions.create && userCasesPermissions.read ? (
|
||||
<EuiFlexItem>
|
||||
<AddToCaseButton timelineId={timelineId} />
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
<EuiFlexItem data-test-subj="inspect-timeline-action">
|
||||
<InspectButton
|
||||
compact={mode === 'compact'}
|
||||
queryId={`${timelineId}-${activeTab}`}
|
||||
inputId={InputsModelId.timeline}
|
||||
isDisabled={isInspectButtonDisabled}
|
||||
title=""
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem data-test-subj="save-timeline-action">
|
||||
<SaveTimelineButton timelineId={timelineId} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export const TimelineActionMenu = React.memo(TimelineActionMenuComponent);
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* 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 { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiPopover } from '@elastic/eui';
|
||||
import React, { useMemo, useState, useCallback } from 'react';
|
||||
import { NewTimeline } from '../../timeline/properties/helpers';
|
||||
import { NewTemplateTimeline } from '../../timeline/properties/new_template_timeline';
|
||||
import * as i18n from './translations';
|
||||
|
||||
interface NewTimelineActionProps {
|
||||
timelineId: string;
|
||||
}
|
||||
|
||||
const panelStyle = {
|
||||
padding: 0,
|
||||
};
|
||||
|
||||
export const NewTimelineAction = React.memo(({ timelineId }: NewTimelineActionProps) => {
|
||||
const [isPopoverOpen, setPopover] = useState(false);
|
||||
|
||||
const closePopover = useCallback(() => {
|
||||
setPopover(false);
|
||||
}, []);
|
||||
|
||||
const togglePopover = useCallback(() => setPopover((prev) => !prev), []);
|
||||
|
||||
const newTimelineActionbtn = useMemo(() => {
|
||||
return (
|
||||
<EuiButtonEmpty iconType="arrowDown" size="s" iconSide="right" onClick={togglePopover}>
|
||||
{i18n.NEW_TIMELINE_BTN}
|
||||
</EuiButtonEmpty>
|
||||
);
|
||||
}, [togglePopover]);
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
button={newTimelineActionbtn}
|
||||
isOpen={isPopoverOpen}
|
||||
closePopover={closePopover}
|
||||
panelStyle={panelStyle}
|
||||
>
|
||||
<EuiFlexGroup gutterSize="xs" direction="column" alignItems="flexStart">
|
||||
<EuiFlexItem>
|
||||
<NewTimeline timelineId={timelineId} title={i18n.NEW_TIMELINE} onClick={closePopover} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<NewTemplateTimeline
|
||||
timelineId={timelineId}
|
||||
title={i18n.NEW_TEMPLATE_TIMELINE}
|
||||
onClick={closePopover}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPopover>
|
||||
);
|
||||
});
|
||||
|
||||
NewTimelineAction.displayName = 'NewTimelineAction';
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* 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 { EuiButtonEmpty } from '@elastic/eui';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { OpenTimelineModal } from '../../open_timeline/open_timeline_modal';
|
||||
import type { ActionTimelineToShow } from '../../open_timeline/types';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const actionTimelineToHide: ActionTimelineToShow[] = ['createFrom'];
|
||||
|
||||
export const OpenTimelineAction = React.memo(() => {
|
||||
const [showTimelineModal, setShowTimelineModal] = useState(false);
|
||||
const onCloseTimelineModal = useCallback(() => setShowTimelineModal(false), []);
|
||||
const onOpenTimelineModal = useCallback(() => {
|
||||
setShowTimelineModal(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="open-timeline-button"
|
||||
onClick={onOpenTimelineModal}
|
||||
aria-label={i18n.OPEN_TIMELINE_BTN_LABEL}
|
||||
>
|
||||
{i18n.OPEN_TIMELINE_BTN}
|
||||
</EuiButtonEmpty>
|
||||
|
||||
{showTimelineModal ? (
|
||||
<OpenTimelineModal onClose={onCloseTimelineModal} hideActions={actionTimelineToHide} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
OpenTimelineAction.displayName = 'OpenTimelineAction';
|
|
@ -11,7 +11,7 @@ import type { SaveTimelineButtonProps } from './save_timeline_button';
|
|||
import { SaveTimelineButton } from './save_timeline_button';
|
||||
import { TestProviders } from '../../../../common/mock';
|
||||
import { useUserPrivileges } from '../../../../common/components/user_privileges';
|
||||
import { getTimelineStatusByIdSelector } from '../../flyout/header/selectors';
|
||||
import { getTimelineStatusByIdSelector } from '../header/selectors';
|
||||
import { TimelineStatus } from '../../../../../common/api/timeline';
|
||||
|
||||
const TEST_ID = {
|
||||
|
@ -28,7 +28,7 @@ jest.mock('react-redux', () => {
|
|||
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
jest.mock('../../../../common/components/user_privileges');
|
||||
jest.mock('../../flyout/header/selectors', () => {
|
||||
jest.mock('../header/selectors', () => {
|
||||
return {
|
||||
getTimelineStatusByIdSelector: jest.fn().mockReturnValue(() => ({
|
||||
status: 'draft',
|
|
@ -8,7 +8,6 @@
|
|||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { EuiButton, EuiToolTip, EuiTourStep, EuiCode, EuiText, EuiButtonEmpty } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { getTimelineStatusByIdSelector } from '../../flyout/header/selectors';
|
||||
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
|
||||
import { TimelineStatus } from '../../../../../common/api/timeline';
|
||||
import { useUserPrivileges } from '../../../../common/components/user_privileges';
|
||||
|
@ -17,6 +16,7 @@ import { useLocalStorage } from '../../../../common/components/local_storage';
|
|||
|
||||
import { SaveTimelineModal } from './save_timeline_modal';
|
||||
import * as timelineTranslations from './translations';
|
||||
import { getTimelineStatusByIdSelector } from '../header/selectors';
|
||||
|
||||
export interface SaveTimelineButtonProps {
|
||||
timelineId: string;
|
||||
|
@ -90,10 +90,11 @@ export const SaveTimelineButton = React.memo<SaveTimelineButtonProps>(({ timelin
|
|||
fill
|
||||
color="primary"
|
||||
onClick={openEditTimeline}
|
||||
size="s"
|
||||
iconType="save"
|
||||
isLoading={isSaving}
|
||||
disabled={!canEditTimeline}
|
||||
data-test-subj="save-timeline-btn"
|
||||
data-test-subj="save-timeline-action-btn"
|
||||
id={SAVE_BUTTON_ELEMENT_ID}
|
||||
>
|
||||
{timelineTranslations.SAVE}
|
|
@ -18,7 +18,7 @@ jest.mock('../../../../common/hooks/use_selector', () => ({
|
|||
useDeepEqualSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../properties/use_create_timeline', () => ({
|
||||
jest.mock('../../timeline/properties/use_create_timeline', () => ({
|
||||
useCreateTimeline: jest.fn(),
|
||||
}));
|
||||
|
|
@ -26,13 +26,13 @@ import { TimelineId } from '../../../../../common/types/timeline';
|
|||
import { TimelineStatus, TimelineType } from '../../../../../common/api/timeline';
|
||||
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
|
||||
import { timelineActions, timelineSelectors } from '../../../store/timeline';
|
||||
import { NOTES_PANEL_WIDTH } from '../properties/notes_size';
|
||||
import { useCreateTimeline } from '../properties/use_create_timeline';
|
||||
import * as commonI18n from '../properties/translations';
|
||||
import * as commonI18n from '../../timeline/properties/translations';
|
||||
import * as i18n from './translations';
|
||||
import { formSchema } from './schema';
|
||||
import { useStartTransaction } from '../../../../common/lib/apm/use_start_transaction';
|
||||
import { TIMELINE_ACTIONS } from '../../../../common/lib/apm/user_actions';
|
||||
import { useCreateTimeline } from '../../timeline/properties/use_create_timeline';
|
||||
import { NOTES_PANEL_WIDTH } from '../../timeline/properties/notes_size';
|
||||
import { formSchema } from './schema';
|
||||
|
||||
const CommonUseField = getUseField({ component: Field });
|
||||
interface SaveTimelineModalProps {
|
|
@ -9,6 +9,55 @@ import { i18n } from '@kbn/i18n';
|
|||
import type { TimelineTypeLiteral } from '../../../../../common/api/timeline';
|
||||
import { TimelineType } from '../../../../../common/api/timeline';
|
||||
|
||||
export const NEW_TIMELINE_BTN = i18n.translate(
|
||||
'xpack.securitySolution.flyout.timeline.actionMenu.newTimelineBtn',
|
||||
{
|
||||
defaultMessage: 'New',
|
||||
}
|
||||
);
|
||||
|
||||
export const NEW_TIMELINE = i18n.translate(
|
||||
'xpack.securitySolution.flyout.timeline.actionMenu.newTimeline',
|
||||
{
|
||||
defaultMessage: 'New Timeline',
|
||||
}
|
||||
);
|
||||
|
||||
export const OPEN_TIMELINE_BTN = i18n.translate(
|
||||
'xpack.securitySolution.flyout.timeline.actionMenu.openTimelineBtn',
|
||||
{
|
||||
defaultMessage: 'Open',
|
||||
}
|
||||
);
|
||||
|
||||
export const OPEN_TIMELINE_BTN_LABEL = i18n.translate(
|
||||
'xpack.securitySolution.flyout.timeline.actionMenu.openTimelineBtnLabel',
|
||||
{
|
||||
defaultMessage: 'Open Existing Timeline',
|
||||
}
|
||||
);
|
||||
|
||||
export const SAVE_TIMELINE_BTN = i18n.translate(
|
||||
'xpack.securitySolution.flyout.timeline.actionMenu.saveTimelineBtn',
|
||||
{
|
||||
defaultMessage: 'Save',
|
||||
}
|
||||
);
|
||||
|
||||
export const SAVE_TIMELINE_BTN_LABEL = i18n.translate(
|
||||
'xpack.securitySolution.flyout.timeline.actionMenu.saveTimelineBtnLabel',
|
||||
{
|
||||
defaultMessage: 'Save currently opened Timeline',
|
||||
}
|
||||
);
|
||||
|
||||
export const NEW_TEMPLATE_TIMELINE = i18n.translate(
|
||||
'xpack.securitySolution.flyout.timeline.actionMenu.newTimelineTemplate',
|
||||
{
|
||||
defaultMessage: 'New Timeline template',
|
||||
}
|
||||
);
|
||||
|
||||
export const CALL_OUT_UNAUTHORIZED_MSG = i18n.translate(
|
||||
'xpack.securitySolution.timeline.callOut.unauthorized.message.description',
|
||||
{
|
||||
|
@ -79,13 +128,6 @@ export const UNSAVED_TIMELINE_WARNING = (timelineType: TimelineTypeLiteral) =>
|
|||
defaultMessage: 'You have an unsaved {timeline}. Do you wish to save it?',
|
||||
});
|
||||
|
||||
export const TITLE = i18n.translate(
|
||||
'xpack.securitySolution.timeline.saveTimeline.modal.titleTitle',
|
||||
{
|
||||
defaultMessage: 'Title',
|
||||
}
|
||||
);
|
||||
|
||||
export const TIMELINE_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.timeline.saveTimeline.modal.titleAriaLabel',
|
||||
{
|
||||
|
@ -114,6 +156,10 @@ export const SAVE_TOUR_CLOSE = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
export const TITLE = i18n.translate('xpack.securitySolution.timeline.saveTimeline.modal.title', {
|
||||
defaultMessage: 'Title',
|
||||
});
|
||||
|
||||
export const SAVE_TOUR_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.timeline.flyout.saveTour.title',
|
||||
{
|
|
@ -70,13 +70,13 @@ const AddTimelineButtonComponent: React.FC<AddTimelineButtonComponentProps> = ({
|
|||
<NewTimeline
|
||||
timelineId={timelineId}
|
||||
title={i18n.NEW_TIMELINE}
|
||||
closeGearMenu={onClosePopover}
|
||||
onClick={onClosePopover}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={false}>
|
||||
<NewTemplateTimeline
|
||||
closeGearMenu={onClosePopover}
|
||||
onClick={onClosePopover}
|
||||
timelineId={timelineId}
|
||||
title={i18n.NEW_TEMPLATE_TIMELINE}
|
||||
/>
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import { pick } from 'lodash/fp';
|
||||
import { EuiButton, EuiContextMenuPanel, EuiContextMenuItem, EuiPopover } from '@elastic/eui';
|
||||
import { EuiContextMenuPanel, EuiContextMenuItem, EuiPopover, EuiButtonEmpty } from '@elastic/eui';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
|
@ -118,8 +118,7 @@ const AddToCaseButtonComponent: React.FC<Props> = ({ timelineId }) => {
|
|||
|
||||
const button = useMemo(
|
||||
() => (
|
||||
<EuiButton
|
||||
fill
|
||||
<EuiButtonEmpty
|
||||
size="m"
|
||||
data-test-subj="attach-timeline-case-button"
|
||||
iconType="arrowDown"
|
||||
|
@ -128,7 +127,7 @@ const AddToCaseButtonComponent: React.FC<Props> = ({ timelineId }) => {
|
|||
disabled={timelineStatus === TimelineStatus.draft || timelineType !== TimelineType.default}
|
||||
>
|
||||
{i18n.ATTACH_TO_CASE}
|
||||
</EuiButton>
|
||||
</EuiButtonEmpty>
|
||||
),
|
||||
[handleButtonClick, timelineStatus, timelineType]
|
||||
);
|
||||
|
|
|
@ -10,6 +10,7 @@ import { FlyoutHeaderPanel } from '../header';
|
|||
|
||||
interface FlyoutBottomBarProps {
|
||||
showTimelineHeaderPanel: boolean;
|
||||
|
||||
timelineId: string;
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* 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 {
|
||||
kibanaObservable,
|
||||
mockGlobalState,
|
||||
SUB_PLUGINS_REDUCER,
|
||||
TestProviders,
|
||||
} from '../../../../common/mock';
|
||||
import React from 'react';
|
||||
import type { ActiveTimelinesProps } from './active_timelines';
|
||||
import { ActiveTimelines } from './active_timelines';
|
||||
import { TimelineId } from '../../../../../common/types';
|
||||
import { TimelineType } from '../../../../../common/api/timeline';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { createSecuritySolutionStorageMock } from '@kbn/timelines-plugin/public/mock/mock_local_storage';
|
||||
import { createStore } from '../../../../common/store';
|
||||
|
||||
const { storage } = createSecuritySolutionStorageMock();
|
||||
|
||||
const store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
|
||||
|
||||
const TestComponent = (props: ActiveTimelinesProps) => {
|
||||
return (
|
||||
<TestProviders store={store}>
|
||||
<ActiveTimelines {...props} />
|
||||
</TestProviders>
|
||||
);
|
||||
};
|
||||
|
||||
describe('ActiveTimelines', () => {
|
||||
describe('default timeline', () => {
|
||||
it('should render timeline title as button when minimized', () => {
|
||||
render(
|
||||
<TestComponent
|
||||
timelineId={'test'}
|
||||
timelineTitle={TimelineId.test}
|
||||
isOpen={false}
|
||||
timelineType={TimelineType.default}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText(/Open timeline timeline-test/).nodeName.toLowerCase()).toBe(
|
||||
'button'
|
||||
);
|
||||
});
|
||||
|
||||
it('should render timeline title as text when maximized', () => {
|
||||
render(
|
||||
<TestComponent
|
||||
timelineId={'test'}
|
||||
timelineTitle={TimelineId.test}
|
||||
isOpen={true}
|
||||
timelineType={TimelineType.default}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByLabelText(/Open timeline timeline-test/)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should maximized timeline when clicked on minimized timeline', async () => {
|
||||
render(
|
||||
<TestComponent
|
||||
timelineId={'test'}
|
||||
timelineTitle={TimelineId.test}
|
||||
isOpen={false}
|
||||
timelineType={TimelineType.default}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/Open timeline timeline-test/));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().timeline.timelineById.test.show).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('template timeline', () => {
|
||||
it('should render timeline template title as button when minimized', () => {
|
||||
render(
|
||||
<TestComponent
|
||||
timelineId="test"
|
||||
timelineTitle=""
|
||||
isOpen={false}
|
||||
timelineType={TimelineType.template}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId(/timeline-title/)).toHaveTextContent(/Untitled template/);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -5,14 +5,13 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiHealth, EuiToolTip } from '@elastic/eui';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiText } from '@elastic/eui';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { isEmpty } from 'lodash/fp';
|
||||
import styled from 'styled-components';
|
||||
import { FormattedRelative } from '@kbn/i18n-react';
|
||||
|
||||
import { TimelineStatus, TimelineType } from '../../../../../common/api/timeline';
|
||||
import { TimelineType } from '../../../../../common/api/timeline';
|
||||
import { TimelineEventsCountBadge } from '../../../../common/hooks/use_timeline_events_count';
|
||||
import {
|
||||
ACTIVE_TIMELINE_BUTTON_CLASS_NAME,
|
||||
|
@ -22,20 +21,18 @@ import { UNTITLED_TIMELINE, UNTITLED_TEMPLATE } from '../../timeline/properties/
|
|||
import { timelineActions } from '../../../store/timeline';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const EuiHealthStyled = styled(EuiHealth)`
|
||||
display: block;
|
||||
`;
|
||||
|
||||
interface ActiveTimelinesProps {
|
||||
export interface ActiveTimelinesProps {
|
||||
timelineId: string;
|
||||
timelineStatus: TimelineStatus;
|
||||
timelineTitle: string;
|
||||
timelineType: TimelineType;
|
||||
isOpen: boolean;
|
||||
updated?: number;
|
||||
}
|
||||
|
||||
const StyledEuiButtonEmpty = styled(EuiButtonEmpty)`
|
||||
&:active,
|
||||
&:focus {
|
||||
background: transparent;
|
||||
}
|
||||
> span {
|
||||
padding: 0;
|
||||
}
|
||||
|
@ -45,17 +42,17 @@ const TitleConatiner = styled(EuiFlexItem)`
|
|||
overflow: hidden;
|
||||
display: inline-block;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const ActiveTimelinesComponent: React.FC<ActiveTimelinesProps> = ({
|
||||
timelineId,
|
||||
timelineStatus,
|
||||
timelineType,
|
||||
timelineTitle,
|
||||
updated,
|
||||
isOpen,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleToggleOpen = useCallback(() => {
|
||||
dispatch(timelineActions.showTimeline({ id: timelineId, show: !isOpen }));
|
||||
focusActiveTimelineButton();
|
||||
|
@ -67,22 +64,35 @@ const ActiveTimelinesComponent: React.FC<ActiveTimelinesProps> = ({
|
|||
? UNTITLED_TEMPLATE
|
||||
: UNTITLED_TIMELINE;
|
||||
|
||||
const tooltipContent = useMemo(() => {
|
||||
if (timelineStatus === TimelineStatus.draft) {
|
||||
return <>{i18n.UNSAVED}</>;
|
||||
}
|
||||
const titleContent = useMemo(() => {
|
||||
return (
|
||||
<>
|
||||
{i18n.SAVED}{' '}
|
||||
<FormattedRelative
|
||||
data-test-subj="timeline-status"
|
||||
key="timeline-status-autosaved"
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
value={new Date(updated!)}
|
||||
/>
|
||||
</>
|
||||
<EuiFlexGroup
|
||||
gutterSize="none"
|
||||
alignItems="center"
|
||||
justifyContent="flexStart"
|
||||
responsive={false}
|
||||
>
|
||||
<TitleConatiner data-test-subj="timeline-title" grow={false}>
|
||||
{isOpen ? (
|
||||
<EuiText grow={false}>
|
||||
<h3>{title}</h3>
|
||||
</EuiText>
|
||||
) : (
|
||||
<>{title}</>
|
||||
)}
|
||||
</TitleConatiner>
|
||||
{!isOpen && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<TimelineEventsCountBadge />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}, [timelineStatus, updated]);
|
||||
}, [isOpen, title]);
|
||||
|
||||
if (isOpen) {
|
||||
return <>{titleContent}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledEuiButtonEmpty
|
||||
|
@ -94,26 +104,7 @@ const ActiveTimelinesComponent: React.FC<ActiveTimelinesProps> = ({
|
|||
isSelected={isOpen}
|
||||
onClick={handleToggleOpen}
|
||||
>
|
||||
<EuiFlexGroup
|
||||
gutterSize="none"
|
||||
alignItems="center"
|
||||
justifyContent="flexStart"
|
||||
responsive={false}
|
||||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiToolTip position="top" content={tooltipContent}>
|
||||
<EuiHealthStyled
|
||||
color={timelineStatus === TimelineStatus.draft ? 'warning' : 'success'}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
<TitleConatiner grow={false}>{title}</TitleConatiner>
|
||||
{!isOpen && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<TimelineEventsCountBadge />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
{titleContent}
|
||||
</StyledEuiButtonEmpty>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,171 +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 from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { useKibana, useGetUserCasesPermissions } from '../../../../common/lib/kibana';
|
||||
import { TestProviders, mockIndexNames, mockIndexPattern } from '../../../../common/mock';
|
||||
import { TimelineId } from '../../../../../common/types/timeline';
|
||||
import { useTimelineKpis } from '../../../containers/kpis';
|
||||
import { FlyoutHeader } from '.';
|
||||
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
|
||||
import { mockBrowserFields } from '../../../../common/containers/source/mock';
|
||||
import { getEmptyValue } from '../../../../common/components/empty_value';
|
||||
import { allCasesPermissions, readCasesPermissions } from '../../../../cases_test_utils';
|
||||
|
||||
const mockUseSourcererDataView: jest.Mock = useSourcererDataView as jest.Mock;
|
||||
jest.mock('../../../../common/containers/sourcerer');
|
||||
|
||||
const mockUseTimelineKpis: jest.Mock = useTimelineKpis as jest.Mock;
|
||||
jest.mock('../../../containers/kpis', () => ({
|
||||
useTimelineKpis: jest.fn(),
|
||||
}));
|
||||
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
jest.mock('@kbn/i18n-react', () => {
|
||||
const originalModule = jest.requireActual('@kbn/i18n-react');
|
||||
const FormattedRelative = jest.fn().mockImplementation(() => '20 hours ago');
|
||||
|
||||
return {
|
||||
...originalModule,
|
||||
FormattedRelative,
|
||||
};
|
||||
});
|
||||
const mockUseTimelineKpiResponse = {
|
||||
processCount: 1,
|
||||
userCount: 1,
|
||||
sourceIpCount: 1,
|
||||
hostCount: 1,
|
||||
destinationIpCount: 1,
|
||||
};
|
||||
|
||||
const mockUseTimelineLargeKpiResponse = {
|
||||
processCount: 1000,
|
||||
userCount: 1000000,
|
||||
sourceIpCount: 1000000000,
|
||||
hostCount: 999,
|
||||
destinationIpCount: 1,
|
||||
};
|
||||
const defaultMocks = {
|
||||
browserFields: mockBrowserFields,
|
||||
indexPattern: mockIndexPattern,
|
||||
loading: false,
|
||||
selectedPatterns: mockIndexNames,
|
||||
};
|
||||
describe('header', () => {
|
||||
beforeEach(() => {
|
||||
// Mocking these services is required for the header component to render.
|
||||
mockUseSourcererDataView.mockImplementation(() => defaultMocks);
|
||||
useKibanaMock().services.application.capabilities = {
|
||||
navLinks: {},
|
||||
management: {},
|
||||
catalogue: {},
|
||||
actions: { show: true, crud: true },
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('AddToCaseButton', () => {
|
||||
beforeEach(() => {
|
||||
mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineKpiResponse]);
|
||||
});
|
||||
|
||||
it('renders the button when the user has create and read permissions', () => {
|
||||
(useGetUserCasesPermissions as jest.Mock).mockReturnValue(allCasesPermissions());
|
||||
|
||||
render(
|
||||
<TestProviders>
|
||||
<FlyoutHeader timelineId={TimelineId.test} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('attach-timeline-case-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the button when the user does not have create permissions', () => {
|
||||
(useGetUserCasesPermissions as jest.Mock).mockReturnValue(readCasesPermissions());
|
||||
|
||||
render(
|
||||
<TestProviders>
|
||||
<FlyoutHeader timelineId={TimelineId.test} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('attach-timeline-case-button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Timeline KPIs', () => {
|
||||
describe('when the data is not loading and the response contains data', () => {
|
||||
beforeEach(() => {
|
||||
mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineKpiResponse]);
|
||||
});
|
||||
it('renders the component, labels and values successfully', () => {
|
||||
render(
|
||||
<TestProviders>
|
||||
<FlyoutHeader timelineId={TimelineId.test} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(screen.getByTestId('siem-timeline-kpis')).toBeInTheDocument();
|
||||
// label
|
||||
expect(screen.getByText('Processes')).toBeInTheDocument();
|
||||
// value
|
||||
expect(screen.getByTestId('siem-timeline-process-kpi').textContent).toContain('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the data is loading', () => {
|
||||
beforeEach(() => {
|
||||
mockUseTimelineKpis.mockReturnValue([true, mockUseTimelineKpiResponse]);
|
||||
});
|
||||
it('renders a loading indicator for values', async () => {
|
||||
render(
|
||||
<TestProviders>
|
||||
<FlyoutHeader timelineId={TimelineId.test} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(screen.getAllByText('--')).not.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the response is null and timeline is blank', () => {
|
||||
beforeEach(() => {
|
||||
mockUseTimelineKpis.mockReturnValue([false, null]);
|
||||
});
|
||||
it('renders labels and the default empty string', () => {
|
||||
render(
|
||||
<TestProviders>
|
||||
<FlyoutHeader timelineId={TimelineId.test} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(screen.getByText('Processes')).toBeInTheDocument();
|
||||
expect(screen.getAllByText(getEmptyValue())).not.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the response contains numbers larger than one thousand', () => {
|
||||
beforeEach(() => {
|
||||
mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineLargeKpiResponse]);
|
||||
});
|
||||
it('formats the numbers correctly', () => {
|
||||
render(
|
||||
<TestProviders>
|
||||
<FlyoutHeader timelineId={TimelineId.test} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(screen.getByText('1k', { selector: '.euiTitle' })).toBeInTheDocument();
|
||||
expect(screen.getByText('1m', { selector: '.euiTitle' })).toBeInTheDocument();
|
||||
expect(screen.getByText('1b', { selector: '.euiTitle' })).toBeInTheDocument();
|
||||
expect(screen.getByText('999', { selector: '.euiTitle' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -5,70 +5,48 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiPanel,
|
||||
EuiToolTip,
|
||||
EuiButtonIcon,
|
||||
EuiText,
|
||||
EuiButtonEmpty,
|
||||
useEuiTheme,
|
||||
EuiTextColor,
|
||||
} from '@elastic/eui';
|
||||
import { FormattedRelative } from '@kbn/i18n-react';
|
||||
import type { MouseEventHandler } from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiToolTip, EuiButtonIcon } from '@elastic/eui';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { isEmpty, get, pick } from 'lodash/fp';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { getEsQueryConfig } from '@kbn/data-plugin/common';
|
||||
import { InputsModelId } from '../../../../common/store/inputs/constants';
|
||||
import { euiStyled } from '@kbn/kibana-react-plugin/common';
|
||||
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
|
||||
import { TimelineTabs, TimelineId } from '../../../../../common/types/timeline';
|
||||
import { TimelineStatus, TimelineType } from '../../../../../common/api/timeline';
|
||||
import type { State } from '../../../../common/store';
|
||||
import { timelineActions, timelineSelectors } from '../../../store/timeline';
|
||||
import { timelineDefaults } from '../../../store/timeline/defaults';
|
||||
import { AddToFavoritesButton } from '../../timeline/properties/helpers';
|
||||
import type { TimerangeInput } from '../../../../../common/search_strategy';
|
||||
import { AddToCaseButton } from '../add_to_case_button';
|
||||
import { AddTimelineButton } from '../add_timeline_button';
|
||||
import { useGetUserCasesPermissions, useKibana } from '../../../../common/lib/kibana';
|
||||
import { InspectButton } from '../../../../common/components/inspect';
|
||||
import { useTimelineKpis } from '../../../containers/kpis';
|
||||
import type { State } from '../../../../common/store';
|
||||
import { useKibana } from '../../../../common/lib/kibana';
|
||||
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
|
||||
import type { TimelineModel } from '../../../store/timeline/model';
|
||||
import {
|
||||
startSelector,
|
||||
endSelector,
|
||||
} from '../../../../common/components/super_date_picker/selectors';
|
||||
import { focusActiveTimelineButton } from '../../timeline/helpers';
|
||||
import { combineQueries } from '../../../../common/lib/kuery';
|
||||
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
|
||||
import { ActiveTimelines } from './active_timelines';
|
||||
import * as i18n from './translations';
|
||||
import * as commonI18n from '../../timeline/properties/translations';
|
||||
import { TimelineKPIs } from './kpis';
|
||||
import { TimelineActionMenu } from '../action_menu';
|
||||
import { AddToFavoritesButton } from '../../timeline/properties/helpers';
|
||||
import { TimelineStatusInfo } from './timeline_status_info';
|
||||
|
||||
import { setActiveTabTimeline } from '../../../store/timeline/actions';
|
||||
import { useIsOverflow } from '../../../../common/hooks/use_is_overflow';
|
||||
import { SaveTimelineButton } from '../../timeline/header/save_timeline_button';
|
||||
import { TimelineSavePrompt } from '../../timeline/header/timeline_save_prompt';
|
||||
|
||||
interface FlyoutHeaderProps {
|
||||
export interface FlyoutHeaderPanelProps {
|
||||
timelineId: string;
|
||||
}
|
||||
|
||||
interface FlyoutHeaderPanelProps {
|
||||
timelineId: string;
|
||||
}
|
||||
const FlyoutHeaderPanelContentFlexGroupContainer = styled(EuiFlexGroup)`
|
||||
overflow-x: auto;
|
||||
`;
|
||||
|
||||
const ActiveTimelinesContainer = styled(EuiFlexItem)`
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const TimelinePanel = euiStyled(EuiPanel)<{ $isOpen?: boolean }>`
|
||||
backgroundColor: ${(props) => props.theme.eui.euiColorEmptyShade};
|
||||
color: ${(props) => props.theme.eui.euiTextColor};
|
||||
padding-inline: ${(props) => props.theme.eui.euiSizeM};
|
||||
border-radius: ${({ $isOpen, theme }) => ($isOpen ? theme.eui.euiBorderRadius : '0px')};
|
||||
`;
|
||||
|
||||
const FlyoutHeaderPanelComponent: React.FC<FlyoutHeaderPanelProps> = ({ timelineId }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { browserFields, indexPattern } = useSourcererDataView(SourcererScopeName.timeline);
|
||||
|
@ -86,6 +64,7 @@ const FlyoutHeaderPanelComponent: React.FC<FlyoutHeaderPanelProps> = ({ timeline
|
|||
show,
|
||||
filters,
|
||||
kqlMode,
|
||||
changed = false,
|
||||
} = useDeepEqualSelector((state) =>
|
||||
pick(
|
||||
[
|
||||
|
@ -99,6 +78,7 @@ const FlyoutHeaderPanelComponent: React.FC<FlyoutHeaderPanelProps> = ({ timeline
|
|||
'show',
|
||||
'filters',
|
||||
'kqlMode',
|
||||
'changed',
|
||||
],
|
||||
getTimeline(state, timelineId) ?? timelineDefaults
|
||||
)
|
||||
|
@ -109,14 +89,15 @@ const FlyoutHeaderPanelComponent: React.FC<FlyoutHeaderPanelProps> = ({ timeline
|
|||
);
|
||||
|
||||
const getKqlQueryTimeline = useMemo(() => timelineSelectors.getKqlFilterQuerySelector(), []);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const kqlQueryTimeline = useSelector((state: State) => getKqlQueryTimeline(state, timelineId)!);
|
||||
|
||||
const kqlQueryTimeline = useSelector((state: State) => getKqlQueryTimeline(state, timelineId));
|
||||
|
||||
const kqlQueryExpression =
|
||||
isEmpty(dataProviders) && isEmpty(kqlQueryTimeline) && timelineType === 'template'
|
||||
? ' '
|
||||
: kqlQueryTimeline;
|
||||
const kqlQueryTest = useMemo(
|
||||
: kqlQueryTimeline ?? '';
|
||||
|
||||
const kqlQueryObj = useMemo(
|
||||
() => ({ query: kqlQueryExpression, language: 'kuery' }),
|
||||
[kqlQueryExpression]
|
||||
);
|
||||
|
@ -129,10 +110,10 @@ const FlyoutHeaderPanelComponent: React.FC<FlyoutHeaderPanelProps> = ({ timeline
|
|||
indexPattern,
|
||||
browserFields,
|
||||
filters: filters ? filters : [],
|
||||
kqlQuery: kqlQueryTest,
|
||||
kqlQuery: kqlQueryObj,
|
||||
kqlMode,
|
||||
}),
|
||||
[browserFields, dataProviders, esQueryConfig, filters, indexPattern, kqlMode, kqlQueryTest]
|
||||
[browserFields, dataProviders, esQueryConfig, filters, indexPattern, kqlMode, kqlQueryObj]
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
|
@ -140,44 +121,57 @@ const FlyoutHeaderPanelComponent: React.FC<FlyoutHeaderPanelProps> = ({ timeline
|
|||
focusActiveTimelineButton();
|
||||
}, [dispatch, timelineId]);
|
||||
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
return (
|
||||
<EuiPanel
|
||||
borderRadius="none"
|
||||
<TimelinePanel
|
||||
$isOpen={show}
|
||||
grow={false}
|
||||
paddingSize="s"
|
||||
hasShadow={false}
|
||||
data-test-subj="timeline-flyout-header-panel"
|
||||
style={{ backgroundColor: euiTheme.colors.emptyShade, color: euiTheme.colors.text }}
|
||||
data-show={show}
|
||||
>
|
||||
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
|
||||
<AddTimelineButton timelineId={timelineId} />
|
||||
<ActiveTimelinesContainer grow={false}>
|
||||
<ActiveTimelines
|
||||
timelineId={timelineId}
|
||||
timelineType={timelineType}
|
||||
timelineTitle={title}
|
||||
timelineStatus={timelineStatus}
|
||||
isOpen={show}
|
||||
updated={updated}
|
||||
/>
|
||||
</ActiveTimelinesContainer>
|
||||
<FlyoutHeaderPanelContentFlexGroupContainer
|
||||
className="eui-scrollBar"
|
||||
alignItems="center"
|
||||
gutterSize="s"
|
||||
responsive={false}
|
||||
justifyContent="spaceBetween"
|
||||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup gutterSize="xs" alignItems="center" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<ActiveTimelinesContainer grow={false}>
|
||||
<ActiveTimelines
|
||||
timelineId={timelineId}
|
||||
timelineType={timelineType}
|
||||
timelineTitle={title}
|
||||
isOpen={show}
|
||||
/>
|
||||
</ActiveTimelinesContainer>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<TimelineStatusInfo status={timelineStatus} updated={updated} changed={changed} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<AddToFavoritesButton timelineId={timelineId} compact />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
{show && (
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup justifyContent="flexEnd" gutterSize="s" responsive={false}>
|
||||
{(activeTab === TimelineTabs.query || activeTab === TimelineTabs.eql) && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<InspectButton
|
||||
compact
|
||||
queryId={`${timelineId}-${activeTab}`}
|
||||
inputId={InputsModelId.timeline}
|
||||
inspectIndex={0}
|
||||
isDisabled={!isDataInTimeline || combinedQueries?.filterQuery === undefined}
|
||||
title={i18n.INSPECT_TIMELINE_TITLE}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup
|
||||
justifyContent="flexEnd"
|
||||
alignItems="center"
|
||||
gutterSize="s"
|
||||
responsive={false}
|
||||
>
|
||||
<TimelineActionMenu
|
||||
timelineId={timelineId}
|
||||
activeTab={activeTab}
|
||||
isInspectButtonDisabled={
|
||||
!isDataInTimeline || combinedQueries?.filterQuery === undefined
|
||||
}
|
||||
/>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiToolTip content={i18n.CLOSE_TIMELINE_OR_TEMPLATE(timelineType === 'default')}>
|
||||
<EuiButtonIcon
|
||||
|
@ -191,272 +185,9 @@ const FlyoutHeaderPanelComponent: React.FC<FlyoutHeaderPanelProps> = ({ timeline
|
|||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
</FlyoutHeaderPanelContentFlexGroupContainer>
|
||||
</TimelinePanel>
|
||||
);
|
||||
};
|
||||
|
||||
export const FlyoutHeaderPanel = React.memo(FlyoutHeaderPanelComponent);
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
`;
|
||||
|
||||
const ReadMoreButton = ({
|
||||
description,
|
||||
onclick,
|
||||
}: {
|
||||
description: string;
|
||||
onclick: MouseEventHandler<HTMLButtonElement>;
|
||||
}) => {
|
||||
const [isOverflow, ref] = useIsOverflow(description);
|
||||
return (
|
||||
<>
|
||||
<StyledDiv ref={ref}>{description}</StyledDiv>
|
||||
{isOverflow && (
|
||||
<EuiButtonEmpty flush="left" onClick={onclick}>
|
||||
{i18n.READ_MORE}
|
||||
</EuiButtonEmpty>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledTimelineHeader = styled(EuiFlexGroup)`
|
||||
${({ theme }) => `margin: ${theme.eui.euiSizeXS} ${theme.eui.euiSizeS} 0 ${theme.eui.euiSizeS};`}
|
||||
flex: 0;
|
||||
`;
|
||||
|
||||
const TimelineStatusInfoContainer = styled.span`
|
||||
${({ theme }) => `margin-left: ${theme.eui.euiSizeS};`}
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const KpisContainer = styled.div`
|
||||
${({ theme }) => `margin-right: ${theme.eui.euiSizeM};`}
|
||||
`;
|
||||
|
||||
const RowFlexItem = styled(EuiFlexItem)`
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const TimelineTitleContainer = styled.h3`
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
word-break: break-word;
|
||||
`;
|
||||
|
||||
const TimelineNameComponent: React.FC<FlyoutHeaderProps> = ({ timelineId }) => {
|
||||
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
|
||||
const { title, timelineType } = useDeepEqualSelector((state) =>
|
||||
pick(['title', 'timelineType'], getTimeline(state, timelineId) ?? timelineDefaults)
|
||||
);
|
||||
const placeholder = useMemo(
|
||||
() =>
|
||||
timelineType === TimelineType.template
|
||||
? commonI18n.UNTITLED_TEMPLATE
|
||||
: commonI18n.UNTITLED_TIMELINE,
|
||||
[timelineType]
|
||||
);
|
||||
|
||||
const content = useMemo(() => title || placeholder, [title, placeholder]);
|
||||
|
||||
return (
|
||||
<EuiToolTip content={content} position="bottom">
|
||||
<EuiText>
|
||||
<TimelineTitleContainer data-test-subj="timeline-title">{content}</TimelineTitleContainer>
|
||||
</EuiText>
|
||||
</EuiToolTip>
|
||||
);
|
||||
};
|
||||
|
||||
const TimelineName = React.memo(TimelineNameComponent);
|
||||
|
||||
const TimelineDescriptionComponent: React.FC<{ timelineId: string; description?: string }> = ({
|
||||
timelineId,
|
||||
description,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onReadMore = useCallback(() => {
|
||||
dispatch(
|
||||
setActiveTabTimeline({
|
||||
id: timelineId,
|
||||
activeTab: TimelineTabs.notes,
|
||||
scrollToTop: true,
|
||||
})
|
||||
);
|
||||
}, [dispatch, timelineId]);
|
||||
|
||||
const hasDescription = !!description;
|
||||
return hasDescription ? (
|
||||
<EuiText size="s" data-test-subj="timeline-description">
|
||||
<ReadMoreButton description={description} onclick={onReadMore} />
|
||||
</EuiText>
|
||||
) : null;
|
||||
};
|
||||
|
||||
const TimelineDescription = React.memo(TimelineDescriptionComponent);
|
||||
|
||||
const TimelineStatusInfoComponent = React.memo<{
|
||||
status: TimelineStatus;
|
||||
updated?: number;
|
||||
changed?: boolean;
|
||||
}>(({ status, updated, changed }) => {
|
||||
const isUnsaved = status === TimelineStatus.draft;
|
||||
|
||||
let statusContent: React.ReactNode = null;
|
||||
if (isUnsaved) {
|
||||
statusContent = <EuiTextColor color="warning">{i18n.UNSAVED}</EuiTextColor>;
|
||||
} else if (changed) {
|
||||
statusContent = <EuiTextColor color="warning">{i18n.UNSAVED_CHANGES}</EuiTextColor>;
|
||||
} else {
|
||||
statusContent = (
|
||||
<>
|
||||
{i18n.SAVED}{' '}
|
||||
<FormattedRelative
|
||||
data-test-subj="timeline-status"
|
||||
key="timeline-status-autosaved"
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
value={new Date(updated!)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<EuiText size="xs" data-test-subj="timeline-status">
|
||||
{statusContent}
|
||||
</EuiText>
|
||||
);
|
||||
});
|
||||
TimelineStatusInfoComponent.displayName = 'TimelineStatusInfoComponent';
|
||||
|
||||
const FlyoutHeaderComponent: React.FC<FlyoutHeaderProps> = ({ timelineId }) => {
|
||||
const { selectedPatterns, indexPattern, browserFields } = useSourcererDataView(
|
||||
SourcererScopeName.timeline
|
||||
);
|
||||
const getStartSelector = useMemo(() => startSelector(), []);
|
||||
const getEndSelector = useMemo(() => endSelector(), []);
|
||||
const isActive = useMemo(() => timelineId === TimelineId.active, [timelineId]);
|
||||
const timerange: TimerangeInput = useDeepEqualSelector((state) => {
|
||||
if (isActive) {
|
||||
return {
|
||||
from: getStartSelector(state.inputs.timeline),
|
||||
to: getEndSelector(state.inputs.timeline),
|
||||
interval: '',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
from: getStartSelector(state.inputs.global),
|
||||
to: getEndSelector(state.inputs.global),
|
||||
interval: '',
|
||||
};
|
||||
}
|
||||
});
|
||||
const { uiSettings } = useKibana().services;
|
||||
const esQueryConfig = useMemo(() => getEsQueryConfig(uiSettings), [uiSettings]);
|
||||
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
|
||||
const timeline: TimelineModel = useSelector(
|
||||
(state: State) => getTimeline(state, timelineId) ?? timelineDefaults
|
||||
);
|
||||
const { dataProviders, filters, timelineType, kqlMode, activeTab } = timeline;
|
||||
const getKqlQueryTimeline = useMemo(() => timelineSelectors.getKqlFilterQuerySelector(), []);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const kqlQueryTimeline = useSelector((state: State) => getKqlQueryTimeline(state, timelineId)!);
|
||||
|
||||
const kqlQueryExpression =
|
||||
isEmpty(dataProviders) && isEmpty(kqlQueryTimeline) && timelineType === 'template'
|
||||
? ' '
|
||||
: kqlQueryTimeline;
|
||||
const kqlQuery = useMemo(
|
||||
() => ({ query: kqlQueryExpression, language: 'kuery' }),
|
||||
[kqlQueryExpression]
|
||||
);
|
||||
|
||||
const combinedQueries = useMemo(
|
||||
() =>
|
||||
combineQueries({
|
||||
config: esQueryConfig,
|
||||
dataProviders,
|
||||
indexPattern,
|
||||
browserFields,
|
||||
filters: filters ? filters : [],
|
||||
kqlQuery,
|
||||
kqlMode,
|
||||
}),
|
||||
[browserFields, dataProviders, esQueryConfig, filters, indexPattern, kqlMode, kqlQuery]
|
||||
);
|
||||
|
||||
const isBlankTimeline: boolean = useMemo(
|
||||
() =>
|
||||
(isEmpty(dataProviders) && isEmpty(filters) && isEmpty(kqlQuery.query)) ||
|
||||
combinedQueries?.filterQuery === undefined,
|
||||
[dataProviders, filters, kqlQuery, combinedQueries]
|
||||
);
|
||||
|
||||
const [loading, kpis] = useTimelineKpis({
|
||||
defaultIndex: selectedPatterns,
|
||||
timerange,
|
||||
isBlankTimeline,
|
||||
filterQuery: combinedQueries?.filterQuery ?? '',
|
||||
});
|
||||
|
||||
const userCasesPermissions = useGetUserCasesPermissions();
|
||||
return (
|
||||
<StyledTimelineHeader alignItems="center" gutterSize="s">
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup data-test-subj="properties-left" direction="column" gutterSize="none">
|
||||
<RowFlexItem>
|
||||
<TimelineName timelineId={timelineId} />
|
||||
<TimelineStatusInfoContainer>
|
||||
<TimelineStatusInfoComponent
|
||||
status={timeline.status}
|
||||
changed={timeline.changed}
|
||||
updated={timeline.updated}
|
||||
/>
|
||||
</TimelineStatusInfoContainer>
|
||||
<TimelineSavePrompt timelineId={timelineId} />
|
||||
</RowFlexItem>
|
||||
<RowFlexItem>
|
||||
<TimelineDescription timelineId={timelineId} description={timeline.description} />
|
||||
</RowFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={false}>
|
||||
<KpisContainer>
|
||||
{activeTab === TimelineTabs.query ? (
|
||||
<TimelineKPIs kpis={kpis} isLoading={loading} />
|
||||
) : null}
|
||||
</KpisContainer>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
<AddToFavoritesButton timelineId={timelineId} />
|
||||
</EuiFlexItem>
|
||||
{userCasesPermissions.create && userCasesPermissions.read && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<AddToCaseButton timelineId={timelineId} />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem grow={false}>
|
||||
<SaveTimelineButton timelineId={timelineId} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</StyledTimelineHeader>
|
||||
);
|
||||
};
|
||||
|
||||
FlyoutHeaderComponent.displayName = 'FlyoutHeaderComponent';
|
||||
|
||||
export const FlyoutHeader = React.memo(FlyoutHeaderComponent);
|
||||
|
|
|
@ -1,110 +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, { useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { EuiStat, EuiFlexItem, EuiFlexGroup, EuiToolTip } from '@elastic/eui';
|
||||
import numeral from '@elastic/numeral';
|
||||
import { DEFAULT_NUMBER_FORMAT } from '../../../../../common/constants';
|
||||
import { useUiSetting$ } from '../../../../common/lib/kibana';
|
||||
import type { TimelineKpiStrategyResponse } from '../../../../../common/search_strategy';
|
||||
import { getEmptyValue } from '../../../../common/components/empty_value';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const NoWrapEuiStat = styled(EuiStat)`
|
||||
& .euiStat__description {
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
|
||||
export const TimelineKPIs = React.memo(
|
||||
({ kpis, isLoading }: { kpis: TimelineKpiStrategyResponse | null; isLoading: boolean }) => {
|
||||
const kpiFormat = '0,0.[000]a';
|
||||
const [defaultNumberFormat] = useUiSetting$<string>(DEFAULT_NUMBER_FORMAT);
|
||||
const formattedKpis = useMemo(() => {
|
||||
return {
|
||||
process: kpis === null ? getEmptyValue() : numeral(kpis.processCount).format(kpiFormat),
|
||||
user: kpis === null ? getEmptyValue() : numeral(kpis.userCount).format(kpiFormat),
|
||||
host: kpis === null ? getEmptyValue() : numeral(kpis.hostCount).format(kpiFormat),
|
||||
sourceIp: kpis === null ? getEmptyValue() : numeral(kpis.sourceIpCount).format(kpiFormat),
|
||||
destinationIp:
|
||||
kpis === null ? getEmptyValue() : numeral(kpis.destinationIpCount).format(kpiFormat),
|
||||
};
|
||||
}, [kpis]);
|
||||
const formattedKpiToolTips = useMemo(() => {
|
||||
return {
|
||||
process: numeral(kpis?.processCount).format(defaultNumberFormat),
|
||||
user: numeral(kpis?.userCount).format(defaultNumberFormat),
|
||||
host: numeral(kpis?.hostCount).format(defaultNumberFormat),
|
||||
sourceIp: numeral(kpis?.sourceIpCount).format(defaultNumberFormat),
|
||||
destinationIp: numeral(kpis?.destinationIpCount).format(defaultNumberFormat),
|
||||
};
|
||||
}, [kpis, defaultNumberFormat]);
|
||||
return (
|
||||
<EuiFlexGroup wrap data-test-subj="siem-timeline-kpis">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiToolTip position="left" content={formattedKpiToolTips.process}>
|
||||
<NoWrapEuiStat
|
||||
data-test-subj="siem-timeline-process-kpi"
|
||||
title={formattedKpis.process}
|
||||
description={i18n.PROCESS_KPI_TITLE}
|
||||
titleSize="s"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiToolTip position="left" content={formattedKpiToolTips.user}>
|
||||
<NoWrapEuiStat
|
||||
data-test-subj="siem-timeline-user-kpi"
|
||||
title={formattedKpis.user}
|
||||
description={i18n.USER_KPI_TITLE}
|
||||
titleSize="s"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiToolTip position="left" content={formattedKpiToolTips.host}>
|
||||
<NoWrapEuiStat
|
||||
data-test-subj="siem-timeline-host-kpi"
|
||||
title={formattedKpis.host}
|
||||
description={i18n.HOST_KPI_TITLE}
|
||||
titleSize="s"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiToolTip position="left" content={formattedKpiToolTips.sourceIp}>
|
||||
<NoWrapEuiStat
|
||||
data-test-subj="siem-timeline-source-ip-kpi"
|
||||
title={formattedKpis.sourceIp}
|
||||
description={i18n.SOURCE_IP_KPI_TITLE}
|
||||
titleSize="s"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false} style={{ minWidth: 100 }}>
|
||||
<EuiToolTip position="left" content={formattedKpiToolTips.destinationIp}>
|
||||
<NoWrapEuiStat
|
||||
data-test-subj="siem-timeline-destination-ip-kpi"
|
||||
title={formattedKpis.destinationIp}
|
||||
description={i18n.DESTINATION_IP_KPI_TITLE}
|
||||
titleSize="s"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
TimelineKPIs.displayName = 'TimelineKPIs';
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* 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 from 'react';
|
||||
import { screen, render } from '@testing-library/react';
|
||||
import type { TimelineStatusInfoProps } from './timeline_status_info';
|
||||
import { TimelineStatusInfo } from './timeline_status_info';
|
||||
import { TimelineStatus } from '../../../../../common/api/timeline';
|
||||
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
|
||||
|
||||
const TestComponent = (props: TimelineStatusInfoProps) => {
|
||||
return (
|
||||
<IntlProvider locale="en">
|
||||
<TimelineStatusInfo {...props} />
|
||||
</IntlProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('TestComponent', () => {
|
||||
it('should render the status correctly when timeline is unsaved', () => {
|
||||
render(<TestComponent status={TimelineStatus.draft} />);
|
||||
expect(screen.getByText('Unsaved')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render the status correctly when timeline has unsaved changes', () => {
|
||||
render(<TestComponent status={TimelineStatus.active} changed={true} updated={Date.now()} />);
|
||||
expect(screen.getByText('Has unsaved changes')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render the status correctly when timeline is saved', () => {
|
||||
const updatedTime = Date.now();
|
||||
render(<TestComponent status={TimelineStatus.active} updated={updatedTime} />);
|
||||
expect(screen.getByText('Saved')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render the status correctly when timeline is saved some time ago', () => {
|
||||
const updatedTime = Date.now() - 10000;
|
||||
render(<TestComponent status={TimelineStatus.active} updated={updatedTime} />);
|
||||
expect(screen.getByTestId('timeline-status')).toHaveTextContent(/Saved10 seconds ago/);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* 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 from 'react';
|
||||
import { EuiTextColor, EuiText } from '@elastic/eui';
|
||||
import { FormattedRelative } from '@kbn/i18n-react';
|
||||
|
||||
import styled from 'styled-components';
|
||||
import { TimelineStatus } from '../../../../../common/api/timeline';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const NoWrapText = styled(EuiText)`
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export interface TimelineStatusInfoProps {
|
||||
status: TimelineStatus;
|
||||
updated?: number;
|
||||
changed?: boolean;
|
||||
}
|
||||
|
||||
export const TimelineStatusInfo = React.memo<TimelineStatusInfoProps>(
|
||||
({ status, updated, changed }) => {
|
||||
const isUnsaved = status === TimelineStatus.draft;
|
||||
|
||||
let statusContent: React.ReactNode = null;
|
||||
if (isUnsaved || !updated) {
|
||||
statusContent = <EuiTextColor color="warning">{i18n.UNSAVED}</EuiTextColor>;
|
||||
} else if (changed) {
|
||||
statusContent = <EuiTextColor color="warning">{i18n.UNSAVED_CHANGES}</EuiTextColor>;
|
||||
} else {
|
||||
statusContent = (
|
||||
<>
|
||||
{i18n.SAVED}
|
||||
<FormattedRelative
|
||||
data-test-subj="timeline-status"
|
||||
key="timeline-status-autosaved"
|
||||
value={new Date(updated)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<NoWrapText size="xs" data-test-subj="timeline-status">
|
||||
{statusContent}
|
||||
</NoWrapText>
|
||||
);
|
||||
}
|
||||
);
|
||||
TimelineStatusInfo.displayName = 'TimelineStatusInfo';
|
|
@ -37,35 +37,6 @@ export const INSPECT_TIMELINE_TITLE = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
export const PROCESS_KPI_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.timeline.kpis.processKpiTitle',
|
||||
{
|
||||
defaultMessage: 'Processes',
|
||||
}
|
||||
);
|
||||
|
||||
export const HOST_KPI_TITLE = i18n.translate('xpack.securitySolution.timeline.kpis.hostKpiTitle', {
|
||||
defaultMessage: 'Hosts',
|
||||
});
|
||||
|
||||
export const SOURCE_IP_KPI_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.timeline.kpis.sourceIpKpiTitle',
|
||||
{
|
||||
defaultMessage: 'Source IPs',
|
||||
}
|
||||
);
|
||||
|
||||
export const DESTINATION_IP_KPI_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.timeline.kpis.destinationKpiTitle',
|
||||
{
|
||||
defaultMessage: 'Destination IPs',
|
||||
}
|
||||
);
|
||||
|
||||
export const USER_KPI_TITLE = i18n.translate('xpack.securitySolution.timeline.kpis.userKpiTitle', {
|
||||
defaultMessage: 'Users',
|
||||
});
|
||||
|
||||
export const READ_MORE = i18n.translate('xpack.securitySolution.timeline.properties.readMore', {
|
||||
defaultMessage: 'Read More',
|
||||
});
|
||||
|
|
|
@ -68,9 +68,6 @@ export const usePaneStyles = () => {
|
|||
.timeline-template-badge {
|
||||
border-radius: ${euiTheme.border.radius.medium} ${euiTheme.border.radius.medium} 0 0; // top corners only
|
||||
}
|
||||
.timeline-body {
|
||||
padding: 0 ${euiTheme.size.s};
|
||||
}
|
||||
}
|
||||
`;
|
||||
};
|
||||
|
|
|
@ -33,7 +33,7 @@ const AddNotesContainer = styled.div`
|
|||
AddNotesContainer.displayName = 'AddNotesContainer';
|
||||
|
||||
const ButtonsContainer = styled(EuiFlexGroup)`
|
||||
margin-top: 5px;
|
||||
margin-top: ${({ theme }) => theme.eui.euiSizeS};
|
||||
`;
|
||||
|
||||
ButtonsContainer.displayName = 'ButtonsContainer';
|
||||
|
|
|
@ -122,16 +122,24 @@ Array [
|
|||
/>
|
||||
</span>
|
||||
</div>,
|
||||
<div
|
||||
class="euiFlyoutFooter emotion-euiFlyoutFooter"
|
||||
data-test-subj="side-panel-flyout-footer"
|
||||
.c0 .side-panel-flyout-footer {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
<div
|
||||
class="euiPanel euiPanel--plain euiPanel--paddingMedium c0 emotion-euiPanel-grow-none-m-plain"
|
||||
>
|
||||
<div
|
||||
class="euiFlexGroup emotion-euiFlexGroup-responsive-l-flexEnd-stretch-row"
|
||||
class="euiFlyoutFooter side-panel-flyout-footer emotion-euiFlyoutFooter"
|
||||
data-test-subj="side-panel-flyout-footer"
|
||||
>
|
||||
<div
|
||||
class="euiFlexItem emotion-euiFlexItem-growZero"
|
||||
/>
|
||||
class="euiFlexGroup emotion-euiFlexGroup-responsive-l-flexEnd-stretch-row"
|
||||
>
|
||||
<div
|
||||
class="euiFlexItem emotion-euiFlexItem-growZero"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
]
|
||||
|
@ -164,6 +172,10 @@ exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should
|
|||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
.c2 .side-panel-flyout-footer {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
<div
|
||||
data-eui="EuiFlyout"
|
||||
data-test-subj="timeline:details-panel:flyout"
|
||||
|
@ -255,15 +267,19 @@ exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should
|
|||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlyoutFooter emotion-euiFlyoutFooter"
|
||||
data-test-subj="side-panel-flyout-footer"
|
||||
class="euiPanel euiPanel--plain euiPanel--paddingMedium c2 emotion-euiPanel-grow-none-m-plain"
|
||||
>
|
||||
<div
|
||||
class="euiFlexGroup emotion-euiFlexGroup-responsive-l-flexEnd-stretch-row"
|
||||
class="euiFlyoutFooter side-panel-flyout-footer emotion-euiFlyoutFooter"
|
||||
data-test-subj="side-panel-flyout-footer"
|
||||
>
|
||||
<div
|
||||
class="euiFlexItem emotion-euiFlexItem-growZero"
|
||||
/>
|
||||
class="euiFlexGroup emotion-euiFlexGroup-responsive-l-flexEnd-stretch-row"
|
||||
>
|
||||
<div
|
||||
class="euiFlexItem emotion-euiFlexItem-growZero"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -142,7 +142,10 @@ export const FlyoutFooterComponent = React.memo(
|
|||
|
||||
return (
|
||||
<>
|
||||
<EuiFlyoutFooter data-test-subj="side-panel-flyout-footer">
|
||||
<EuiFlyoutFooter
|
||||
className="side-panel-flyout-footer"
|
||||
data-test-subj="side-panel-flyout-footer"
|
||||
>
|
||||
<EuiFlexGroup justifyContent="flexEnd">
|
||||
<EuiFlexItem grow={false}>
|
||||
{detailsEcsData && (
|
||||
|
|
|
@ -6,8 +6,9 @@
|
|||
*/
|
||||
|
||||
import { useAssistantOverlay } from '@kbn/elastic-assistant';
|
||||
import { EuiSpacer, EuiFlyoutBody } from '@elastic/eui';
|
||||
import { EuiSpacer, EuiFlyoutBody, EuiPanel } from '@elastic/eui';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import deepEqual from 'fast-deep-equal';
|
||||
import type { EntityType } from '@kbn/timelines-plugin/common';
|
||||
|
@ -41,6 +42,12 @@ import {
|
|||
PROMPT_CONTEXTS,
|
||||
} from '../../../../assistant/content/prompt_contexts';
|
||||
|
||||
const FlyoutFooterContainerPanel = styled(EuiPanel)`
|
||||
.side-panel-flyout-footer {
|
||||
background-color: transparent;
|
||||
}
|
||||
`;
|
||||
|
||||
interface EventDetailsPanelProps {
|
||||
browserFields: BrowserFields;
|
||||
entityType?: EntityType;
|
||||
|
@ -254,17 +261,19 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
|
|||
<>
|
||||
{header}
|
||||
{body}
|
||||
<FlyoutFooter
|
||||
detailsData={detailsData}
|
||||
detailsEcsData={ecsData}
|
||||
refetchFlyoutData={refetchFlyoutData}
|
||||
handleOnEventClosed={handleOnEventClosed}
|
||||
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
|
||||
isReadOnly={isReadOnly}
|
||||
loadingEventDetails={loading}
|
||||
onAddIsolationStatusClick={showHostIsolationPanel}
|
||||
scopeId={scopeId}
|
||||
/>
|
||||
<FlyoutFooterContainerPanel hasShadow={false} borderRadius="none">
|
||||
<FlyoutFooter
|
||||
detailsData={detailsData}
|
||||
detailsEcsData={ecsData}
|
||||
refetchFlyoutData={refetchFlyoutData}
|
||||
handleOnEventClosed={handleOnEventClosed}
|
||||
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
|
||||
isReadOnly={isReadOnly}
|
||||
loadingEventDetails={loading}
|
||||
onAddIsolationStatusClick={showHostIsolationPanel}
|
||||
scopeId={scopeId}
|
||||
/>
|
||||
</FlyoutFooterContainerPanel>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -6,10 +6,12 @@
|
|||
*/
|
||||
|
||||
import { rgba } from 'polished';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { IS_DRAGGING_CLASS_NAME } from '@kbn/securitysolution-t-grid';
|
||||
import { EuiToolTip, EuiSuperSelect, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
|
||||
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
|
||||
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
|
||||
|
@ -23,13 +25,16 @@ import { timelineSelectors } from '../../../store/timeline';
|
|||
import { timelineDefaults } from '../../../store/timeline/defaults';
|
||||
|
||||
import * as i18n from './translations';
|
||||
import { options } from '../search_or_filter/helpers';
|
||||
import type { KqlMode } from '../../../store/timeline/model';
|
||||
import { updateKqlMode } from '../../../store/timeline/actions';
|
||||
|
||||
interface Props {
|
||||
timelineId: string;
|
||||
}
|
||||
|
||||
const DropTargetDataProvidersContainer = styled.div`
|
||||
padding: 2px 0 4px 0;
|
||||
position: relative;
|
||||
|
||||
.${IS_DRAGGING_CLASS_NAME} & .drop-target-data-providers {
|
||||
background: ${({ theme }) => rgba(theme.eui.euiColorSuccess, 0.1)};
|
||||
|
@ -49,12 +54,11 @@ const DropTargetDataProviders = styled.div`
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
padding-bottom: 2px;
|
||||
position: relative;
|
||||
border: 0.2rem dashed ${({ theme }) => theme.eui.euiColorMediumShade};
|
||||
border-radius: 5px;
|
||||
padding: ${({ theme }) => theme.eui.euiSizeXS} 0;
|
||||
margin: 2px 0 2px 0;
|
||||
padding: ${({ theme }) => theme.eui.euiSizeS} 0;
|
||||
margin: 0px 0 0px 0;
|
||||
max-height: 33vh;
|
||||
min-height: 100px;
|
||||
overflow: auto;
|
||||
|
@ -84,7 +88,24 @@ const getDroppableId = (id: string): string =>
|
|||
* the user to drop anything with a facet count into
|
||||
* the data pro section.
|
||||
*/
|
||||
|
||||
const timelineSelectModeItemsClassName = 'timelineSelectModeItemsClassName';
|
||||
|
||||
const searchOrFilterPopoverClassName = 'searchOrFilterPopover';
|
||||
const searchOrFilterPopoverWidth = 350;
|
||||
|
||||
const popoverProps = {
|
||||
className: searchOrFilterPopoverClassName,
|
||||
panelClassName: searchOrFilterPopoverClassName,
|
||||
panelMinWidth: searchOrFilterPopoverWidth,
|
||||
};
|
||||
|
||||
const CustomTooltipDiv = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export const DataProviders = React.memo<Props>(({ timelineId }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { browserFields } = useSourcererDataView(SourcererScopeName.timeline);
|
||||
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
|
||||
|
||||
|
@ -96,28 +117,65 @@ export const DataProviders = React.memo<Props>(({ timelineId }) => {
|
|||
);
|
||||
const droppableId = useMemo(() => getDroppableId(timelineId), [timelineId]);
|
||||
|
||||
const kqlMode = useDeepEqualSelector(
|
||||
(state) => (getTimeline(state, timelineId) ?? timelineDefaults).kqlMode
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(mode: KqlMode) => {
|
||||
dispatch(updateKqlMode({ id: timelineId, kqlMode: mode }));
|
||||
},
|
||||
[timelineId, dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<DropTargetDataProvidersContainer
|
||||
aria-label={i18n.QUERY_AREA_ARIA_LABEL}
|
||||
className="drop-target-data-providers-container"
|
||||
>
|
||||
<DropTargetDataProviders
|
||||
className="drop-target-data-providers"
|
||||
data-test-subj="dataProviders"
|
||||
<>
|
||||
<DropTargetDataProvidersContainer
|
||||
aria-label={i18n.QUERY_AREA_ARIA_LABEL}
|
||||
className="drop-target-data-providers-container"
|
||||
>
|
||||
{dataProviders != null && dataProviders.length ? (
|
||||
<Providers
|
||||
browserFields={browserFields}
|
||||
timelineId={timelineId}
|
||||
dataProviders={dataProviders}
|
||||
/>
|
||||
) : (
|
||||
<DroppableWrapper isDropDisabled={isLoading} droppableId={droppableId}>
|
||||
<Empty browserFields={browserFields} timelineId={timelineId} />
|
||||
</DroppableWrapper>
|
||||
)}
|
||||
</DropTargetDataProviders>
|
||||
</DropTargetDataProvidersContainer>
|
||||
<EuiFlexGroup direction={'row'} justifyContent="flexStart" gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
<CustomTooltipDiv>
|
||||
<EuiToolTip
|
||||
className="timeline-select-search-filter-tooltip"
|
||||
content={i18n.FILTER_OR_SEARCH_WITH_KQL}
|
||||
>
|
||||
<EuiSuperSelect
|
||||
className="timeline-select-search-or-filter"
|
||||
data-test-subj="timeline-select-search-or-filter"
|
||||
hasDividers={true}
|
||||
itemLayoutAlign="top"
|
||||
itemClassName={timelineSelectModeItemsClassName}
|
||||
onChange={handleChange}
|
||||
options={options}
|
||||
popoverProps={popoverProps}
|
||||
valueOfSelected={kqlMode}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
</CustomTooltipDiv>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={true}>
|
||||
<DropTargetDataProviders
|
||||
className="drop-target-data-providers"
|
||||
data-test-subj="dataProviders"
|
||||
>
|
||||
{dataProviders != null && dataProviders.length ? (
|
||||
<Providers
|
||||
browserFields={browserFields}
|
||||
timelineId={timelineId}
|
||||
dataProviders={dataProviders}
|
||||
/>
|
||||
) : (
|
||||
<DroppableWrapper isDropDisabled={isLoading} droppableId={droppableId}>
|
||||
<Empty browserFields={browserFields} timelineId={timelineId} />
|
||||
</DroppableWrapper>
|
||||
)}
|
||||
</DropTargetDataProviders>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</DropTargetDataProvidersContainer>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -66,8 +66,10 @@ const getItemStyle = (
|
|||
const DroppableContainer = styled.div`
|
||||
min-height: ${ROW_OF_DATA_PROVIDERS_HEIGHT}px;
|
||||
height: auto !important;
|
||||
display: none;
|
||||
|
||||
.${IS_DRAGGING_CLASS_NAME} &:hover {
|
||||
display: flex;
|
||||
background-color: ${({ theme }) => rgba(theme.eui.euiColorSuccess, 0.2)} !important;
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -169,3 +169,10 @@ export const GROUP_AREA_ARIA_LABEL = (group: number) =>
|
|||
values: { group },
|
||||
defaultMessage: 'You are in group {group}',
|
||||
});
|
||||
|
||||
export const FILTER_OR_SEARCH_WITH_KQL = i18n.translate(
|
||||
'xpack.securitySolution.timeline.searchOrFilter.filterOrSearchWithKql',
|
||||
{
|
||||
defaultMessage: 'Filter or Search with KQL',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -39,8 +39,10 @@ const TimelineDatePickerLockComponent = () => {
|
|||
<EuiButtonIcon
|
||||
data-test-subj={`timeline-date-picker-${isDatePickerLocked ? 'lock' : 'unlock'}-button`}
|
||||
color="primary"
|
||||
size="m"
|
||||
onClick={onToggleLock}
|
||||
iconType={isDatePickerLocked ? 'lock' : 'lockOpen'}
|
||||
display={isDatePickerLocked ? 'fill' : 'base'}
|
||||
aria-label={
|
||||
isDatePickerLocked
|
||||
? i18n.UNLOCK_SYNC_MAIN_DATE_PICKER_ARIA
|
||||
|
|
|
@ -57,12 +57,12 @@ import { Sourcerer } from '../../../../common/components/sourcerer';
|
|||
import { useLicense } from '../../../../common/hooks/use_license';
|
||||
import { HeaderActions } from '../../../../common/components/header_actions/header_actions';
|
||||
|
||||
const TimelineHeaderContainer = styled.div`
|
||||
margin-top: 6px;
|
||||
const EqlTabHeaderContainer = styled.div`
|
||||
margin-top: ${(props) => props.theme.eui.euiSizeS};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
TimelineHeaderContainer.displayName = 'TimelineHeaderContainer';
|
||||
EqlTabHeaderContainer.displayName = 'EqlTabHeaderContainer';
|
||||
|
||||
const StyledEuiFlyoutHeader = styled(EuiFlyoutHeader)`
|
||||
align-items: stretch;
|
||||
|
@ -73,8 +73,7 @@ const StyledEuiFlyoutHeader = styled(EuiFlyoutHeader)`
|
|||
padding: 0;
|
||||
|
||||
&.euiFlyoutHeader {
|
||||
${({ theme }) =>
|
||||
`padding: 0 ${theme.eui.euiSizeM} ${theme.eui.euiSizeS} ${theme.eui.euiSizeS};`}
|
||||
${({ theme }) => `padding: ${theme.eui.euiSizeS} 0 0 0;`}
|
||||
}
|
||||
`;
|
||||
|
||||
|
@ -110,6 +109,7 @@ const FullWidthFlexGroup = styled(EuiFlexGroup)`
|
|||
`;
|
||||
|
||||
const ScrollableFlexItem = styled(EuiFlexItem)`
|
||||
${({ theme }) => `margin: 0 ${theme.eui.euiSizeM};`}
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
|
@ -260,9 +260,11 @@ export const EqlTabContentComponent: React.FC<Props> = ({
|
|||
hasBorder={false}
|
||||
>
|
||||
<EuiFlexGroup
|
||||
alignItems="center"
|
||||
className="euiScrollBar"
|
||||
alignItems="flexStart"
|
||||
gutterSize="s"
|
||||
data-test-subj="timeline-date-picker-container"
|
||||
responsive={false}
|
||||
>
|
||||
{timelineFullScreen && setTimelineFullScreen != null && (
|
||||
<ExitFullScreen
|
||||
|
@ -270,22 +272,22 @@ export const EqlTabContentComponent: React.FC<Props> = ({
|
|||
setFullScreen={setTimelineFullScreen}
|
||||
/>
|
||||
)}
|
||||
<EuiFlexItem grow={10}>
|
||||
<EuiFlexItem grow={false}>
|
||||
{activeTab === TimelineTabs.eql && (
|
||||
<Sourcerer scope={SourcererScopeName.timeline} />
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<SuperDatePicker width="auto" id={InputsModelId.timeline} timelineId={timelineId} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<TimelineDatePickerLock />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={1}>
|
||||
{activeTab === TimelineTabs.eql && (
|
||||
<Sourcerer scope={SourcererScopeName.timeline} />
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<TimelineHeaderContainer data-test-subj="timelineHeader">
|
||||
<EqlQueryBarTimeline timelineId={timelineId} />
|
||||
</TimelineHeaderContainer>
|
||||
</StyledEuiFlyoutHeader>
|
||||
<EqlTabHeaderContainer data-test-subj="timelineHeader">
|
||||
<EqlQueryBarTimeline timelineId={timelineId} />
|
||||
</EqlTabHeaderContainer>
|
||||
|
||||
<EventDetailsWidthProvider>
|
||||
<StyledEuiFlyoutBody
|
||||
|
|
|
@ -1,51 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Header rendering renders correctly against snapshot 1`] = `
|
||||
<Fragment>
|
||||
<DataProviders
|
||||
timelineId="foo"
|
||||
/>
|
||||
<Connect(StatefulSearchOrFilterComponent)
|
||||
filterManager={
|
||||
FilterManager {
|
||||
"extract": [Function],
|
||||
"fetch$": Subject {
|
||||
"closed": false,
|
||||
"currentObservers": null,
|
||||
"hasError": false,
|
||||
"isStopped": false,
|
||||
"observers": Array [],
|
||||
"thrownError": null,
|
||||
},
|
||||
"filters": Array [],
|
||||
"getAllMigrations": [Function],
|
||||
"inject": [Function],
|
||||
"telemetry": [Function],
|
||||
"uiSettings": Object {
|
||||
"get": [MockFunction],
|
||||
"get$": [MockFunction],
|
||||
"getAll": [MockFunction],
|
||||
"getUpdate$": [MockFunction],
|
||||
"getUpdateErrors$": [MockFunction],
|
||||
"isCustom": [MockFunction],
|
||||
"isDeclared": [MockFunction],
|
||||
"isDefault": [MockFunction],
|
||||
"isOverridden": [MockFunction],
|
||||
"remove": [MockFunction],
|
||||
"set": [MockFunction],
|
||||
"validateValue": [MockFunction],
|
||||
},
|
||||
"updated$": Subject {
|
||||
"closed": false,
|
||||
"currentObservers": null,
|
||||
"hasError": false,
|
||||
"isStopped": false,
|
||||
"observers": Array [],
|
||||
"thrownError": null,
|
||||
},
|
||||
}
|
||||
}
|
||||
timelineId="foo"
|
||||
/>
|
||||
</Fragment>
|
||||
`;
|
|
@ -1,59 +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 { EuiCallOut } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import type { FilterManager } from '@kbn/data-plugin/public';
|
||||
|
||||
import { DataProviders } from '../data_providers';
|
||||
import { StatefulSearchOrFilter } from '../search_or_filter';
|
||||
|
||||
import * as i18n from './translations';
|
||||
import type { TimelineStatusLiteralWithNull } from '../../../../../common/api/timeline';
|
||||
import { TimelineStatus } from '../../../../../common/api/timeline';
|
||||
|
||||
interface Props {
|
||||
filterManager: FilterManager;
|
||||
show: boolean;
|
||||
showCallOutUnauthorizedMsg: boolean;
|
||||
status: TimelineStatusLiteralWithNull;
|
||||
timelineId: string;
|
||||
}
|
||||
|
||||
const TimelineHeaderComponent: React.FC<Props> = ({
|
||||
filterManager,
|
||||
show,
|
||||
showCallOutUnauthorizedMsg,
|
||||
status,
|
||||
timelineId,
|
||||
}) => (
|
||||
<>
|
||||
{showCallOutUnauthorizedMsg && (
|
||||
<EuiCallOut
|
||||
data-test-subj="timelineCallOutUnauthorized"
|
||||
title={i18n.CALL_OUT_UNAUTHORIZED_MSG}
|
||||
color="warning"
|
||||
iconType="warning"
|
||||
size="s"
|
||||
/>
|
||||
)}
|
||||
{status === TimelineStatus.immutable && (
|
||||
<EuiCallOut
|
||||
data-test-subj="timelineImmutableCallOut"
|
||||
title={i18n.CALL_OUT_IMMUTABLE}
|
||||
color="primary"
|
||||
iconType="warning"
|
||||
size="s"
|
||||
/>
|
||||
)}
|
||||
{show && <DataProviders timelineId={timelineId} />}
|
||||
|
||||
<StatefulSearchOrFilter filterManager={filterManager} timelineId={timelineId} />
|
||||
</>
|
||||
);
|
||||
|
||||
export const TimelineHeader = React.memo(TimelineHeaderComponent);
|
|
@ -1,59 +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, { useCallback, useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useUserPrivileges } from '../../../../common/components/user_privileges';
|
||||
|
||||
import { TimelineId } from '../../../../../common/types/timeline';
|
||||
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
|
||||
import { timelineActions } from '../../../store/timeline';
|
||||
import { getTimelineSaveModalByIdSelector } from './selectors';
|
||||
import { SaveTimelineModal } from './save_timeline_modal';
|
||||
import { TimelineStatus } from '../../../../../common/api/timeline';
|
||||
|
||||
interface TimelineSavePromptProps {
|
||||
timelineId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the edit timeline modal with a warning that unsaved changes might get lost.
|
||||
* The modal is rendered based on a flag that is set in Redux, in other words, this component
|
||||
* only renders the modal when the flag is triggered from outside this component.
|
||||
*/
|
||||
export const TimelineSavePrompt = React.memo<TimelineSavePromptProps>(({ timelineId }) => {
|
||||
const dispatch = useDispatch();
|
||||
const getTimelineSaveModal = useMemo(() => getTimelineSaveModalByIdSelector(), []);
|
||||
const { showSaveModal: forceShow, status } = useDeepEqualSelector((state) =>
|
||||
getTimelineSaveModal(state, timelineId)
|
||||
);
|
||||
const isUnsaved = status === TimelineStatus.draft;
|
||||
|
||||
const closeSaveTimeline = useCallback(() => {
|
||||
dispatch(
|
||||
timelineActions.toggleModalSaveTimeline({
|
||||
id: TimelineId.active,
|
||||
showModalSaveTimeline: false,
|
||||
})
|
||||
);
|
||||
}, [dispatch]);
|
||||
|
||||
const {
|
||||
kibanaSecuritySolutionsPrivileges: { crud: hasKibanaCrud },
|
||||
} = useUserPrivileges();
|
||||
|
||||
return forceShow && hasKibanaCrud ? (
|
||||
<SaveTimelineModal
|
||||
initialFocusOn={isUnsaved ? 'title' : undefined}
|
||||
closeSaveTimeline={closeSaveTimeline}
|
||||
timelineId={timelineId}
|
||||
showWarning={true}
|
||||
/>
|
||||
) : null;
|
||||
});
|
||||
|
||||
TimelineSavePrompt.displayName = 'TimelineSavePrompt';
|
|
@ -17,7 +17,7 @@ import { timelineDefaults } from '../../store/timeline/defaults';
|
|||
import { defaultHeaders } from './body/column_headers/default_headers';
|
||||
import type { CellValueElementProps } from './cell_rendering';
|
||||
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
|
||||
import { FlyoutHeader, FlyoutHeaderPanel } from '../flyout/header';
|
||||
import { FlyoutHeaderPanel } from '../flyout/header';
|
||||
import type { TimelineId, RowRenderer } from '../../../../common/types/timeline';
|
||||
import { TimelineType } from '../../../../common/api/timeline';
|
||||
import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/hooks/use_selector';
|
||||
|
@ -192,19 +192,18 @@ const StatefulTimelineComponent: React.FC<Props> = ({
|
|||
ref={containerElement}
|
||||
>
|
||||
<TimelineSavingProgress timelineId={timelineId} />
|
||||
{timelineType === TimelineType.template && (
|
||||
<TimelineTemplateBadge className="timeline-template-badge">
|
||||
{i18n.TIMELINE_TEMPLATE}
|
||||
</TimelineTemplateBadge>
|
||||
)}
|
||||
<div className="timeline-body" data-test-subj="timeline-body">
|
||||
{timelineType === TimelineType.template && (
|
||||
<TimelineTemplateBadge className="timeline-template-badge">
|
||||
{i18n.TIMELINE_TEMPLATE}
|
||||
</TimelineTemplateBadge>
|
||||
)}
|
||||
{resolveConflictComponent}
|
||||
<HideShowContainer
|
||||
$isVisible={!timelineFullScreen}
|
||||
data-test-subj="timeline-hide-show-container"
|
||||
>
|
||||
<FlyoutHeaderPanel timelineId={timelineId} />
|
||||
<FlyoutHeader timelineId={timelineId} />
|
||||
</HideShowContainer>
|
||||
|
||||
<TabsContent
|
||||
|
|
|
@ -0,0 +1,100 @@
|
|||
/*
|
||||
* 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 from 'react';
|
||||
import { screen, render } from '@testing-library/react';
|
||||
import { TestProviders } from '../../../../common/mock';
|
||||
import { useTimelineKpis } from '../../../containers/kpis';
|
||||
import { TimelineKpi } from '.';
|
||||
import { TimelineId } from '../../../../../common/types';
|
||||
import { getEmptyValue } from '../../../../common/components/empty_value';
|
||||
|
||||
jest.mock('../../../containers/kpis', () => ({
|
||||
useTimelineKpis: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
|
||||
jest.mock('@kbn/i18n-react', () => {
|
||||
const originalModule = jest.requireActual('@kbn/i18n-react');
|
||||
const FormattedRelative = jest.fn().mockImplementation(() => '20 hours ago');
|
||||
|
||||
return {
|
||||
...originalModule,
|
||||
FormattedRelative,
|
||||
};
|
||||
});
|
||||
|
||||
const mockUseTimelineKpis: jest.Mock = useTimelineKpis as jest.Mock;
|
||||
|
||||
const mockUseTimelineKpiResponse = {
|
||||
processCount: 1,
|
||||
userCount: 1,
|
||||
sourceIpCount: 1,
|
||||
hostCount: 1,
|
||||
destinationIpCount: 1,
|
||||
};
|
||||
|
||||
const mockUseTimelineLargeKpiResponse = {
|
||||
processCount: 1000,
|
||||
userCount: 1000000,
|
||||
sourceIpCount: 1000000000,
|
||||
hostCount: 999,
|
||||
destinationIpCount: 1,
|
||||
};
|
||||
|
||||
describe('Timeline KPIs', () => {
|
||||
describe('when the data is not loading and the response contains data', () => {
|
||||
beforeEach(() => {
|
||||
mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineKpiResponse]);
|
||||
});
|
||||
it('renders the component, labels and values successfully', () => {
|
||||
render(
|
||||
<TestProviders>
|
||||
<TimelineKpi timelineId={TimelineId.test} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(screen.getByTestId('siem-timeline-kpis')).toBeInTheDocument();
|
||||
// label
|
||||
expect(screen.getByText('Processes :')).toBeInTheDocument();
|
||||
// value
|
||||
expect(screen.getByTestId('siem-timeline-process-kpi').textContent).toContain('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the response is null and timeline is blank', () => {
|
||||
beforeEach(() => {
|
||||
mockUseTimelineKpis.mockReturnValue([false, null]);
|
||||
});
|
||||
it('renders labels and the default empty string', () => {
|
||||
render(
|
||||
<TestProviders>
|
||||
<TimelineKpi timelineId={TimelineId.test} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(screen.getByText('Processes :')).toBeInTheDocument();
|
||||
expect(screen.getAllByText(getEmptyValue())).not.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the response contains numbers larger than one thousand', () => {
|
||||
beforeEach(() => {
|
||||
mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineLargeKpiResponse]);
|
||||
});
|
||||
it('formats the numbers correctly', () => {
|
||||
render(
|
||||
<TestProviders>
|
||||
<TimelineKpi timelineId={TimelineId.test} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(screen.getByTitle('1k')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('1m')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('1b')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('999')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { TimelineKpisContainer as TimelineKpi } from './kpi_container';
|
|
@ -0,0 +1,113 @@
|
|||
/*
|
||||
* 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, { useMemo } from 'react';
|
||||
import { isEmpty, pick } from 'lodash/fp';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getEsQueryConfig } from '@kbn/data-plugin/common';
|
||||
import type { TimerangeInput } from '@kbn/timelines-plugin/common';
|
||||
import { EuiPanel } from '@elastic/eui';
|
||||
import { TimelineId } from '../../../../../common/types';
|
||||
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
|
||||
import type { State } from '../../../../common/store';
|
||||
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
|
||||
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
|
||||
import { TimelineKPIs } from './kpis';
|
||||
import { useTimelineKpis } from '../../../containers/kpis';
|
||||
import { useKibana } from '../../../../common/lib/kibana';
|
||||
import { timelineSelectors } from '../../../store/timeline';
|
||||
import { timelineDefaults } from '../../../store/timeline/defaults';
|
||||
import { combineQueries } from '../../../../common/lib/kuery';
|
||||
import {
|
||||
endSelector,
|
||||
startSelector,
|
||||
} from '../../../../common/components/super_date_picker/selectors';
|
||||
|
||||
interface KpiExpandedProps {
|
||||
timelineId: string;
|
||||
}
|
||||
|
||||
export const TimelineKpisContainer = ({ timelineId }: KpiExpandedProps) => {
|
||||
const { browserFields, indexPattern, selectedPatterns } = useSourcererDataView(
|
||||
SourcererScopeName.timeline
|
||||
);
|
||||
|
||||
const { uiSettings } = useKibana().services;
|
||||
const esQueryConfig = useMemo(() => getEsQueryConfig(uiSettings), [uiSettings]);
|
||||
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
|
||||
const { dataProviders, filters, kqlMode } = useDeepEqualSelector((state) =>
|
||||
pick(
|
||||
['dataProviders', 'filters', 'kqlMode'],
|
||||
getTimeline(state, timelineId) ?? timelineDefaults
|
||||
)
|
||||
);
|
||||
|
||||
const getKqlQueryTimeline = useMemo(() => timelineSelectors.getKqlFilterQuerySelector(), []);
|
||||
|
||||
const kqlQueryTimeline = useSelector((state: State) => getKqlQueryTimeline(state, timelineId));
|
||||
|
||||
const kqlQueryExpression = kqlQueryTimeline ?? ' ';
|
||||
|
||||
const kqlQuery = useMemo(
|
||||
() => ({ query: kqlQueryExpression, language: 'kuery' }),
|
||||
[kqlQueryExpression]
|
||||
);
|
||||
|
||||
const isActive = useMemo(() => timelineId === TimelineId.active, [timelineId]);
|
||||
const getStartSelector = useMemo(() => startSelector(), []);
|
||||
const getEndSelector = useMemo(() => endSelector(), []);
|
||||
|
||||
const timerange: TimerangeInput = useDeepEqualSelector((state) => {
|
||||
if (isActive) {
|
||||
return {
|
||||
from: getStartSelector(state.inputs.timeline),
|
||||
to: getEndSelector(state.inputs.timeline),
|
||||
interval: '',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
from: getStartSelector(state.inputs.global),
|
||||
to: getEndSelector(state.inputs.global),
|
||||
interval: '',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const combinedQueries = useMemo(
|
||||
() =>
|
||||
combineQueries({
|
||||
config: esQueryConfig,
|
||||
dataProviders,
|
||||
indexPattern,
|
||||
browserFields,
|
||||
filters: filters ? filters : [],
|
||||
kqlQuery,
|
||||
kqlMode,
|
||||
}),
|
||||
[browserFields, dataProviders, esQueryConfig, filters, indexPattern, kqlMode, kqlQuery]
|
||||
);
|
||||
|
||||
const isBlankTimeline: boolean = useMemo(
|
||||
() =>
|
||||
(isEmpty(dataProviders) && isEmpty(filters) && isEmpty(kqlQuery.query)) ||
|
||||
combinedQueries?.filterQuery === undefined,
|
||||
[dataProviders, filters, kqlQuery, combinedQueries]
|
||||
);
|
||||
|
||||
const [, kpis] = useTimelineKpis({
|
||||
defaultIndex: selectedPatterns,
|
||||
timerange,
|
||||
isBlankTimeline,
|
||||
filterQuery: combinedQueries?.filterQuery ?? '',
|
||||
});
|
||||
|
||||
return (
|
||||
<EuiPanel paddingSize="m" hasBorder>
|
||||
<TimelineKPIs kpis={kpis} />
|
||||
</EuiPanel>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* 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, { useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { EuiFlexItem, EuiFlexGroup, EuiToolTip, EuiBadge } from '@elastic/eui';
|
||||
import numeral from '@elastic/numeral';
|
||||
import { euiThemeVars } from '@kbn/ui-theme';
|
||||
import { DEFAULT_NUMBER_FORMAT } from '../../../../../common/constants';
|
||||
import { useUiSetting$ } from '../../../../common/lib/kibana';
|
||||
import type { TimelineKpiStrategyResponse } from '../../../../../common/search_strategy';
|
||||
import { getEmptyValue } from '../../../../common/components/empty_value';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export const StatsContainer = styled.span`
|
||||
font-size: ${euiThemeVars.euiFontSizeXS};
|
||||
font-weight: ${euiThemeVars.euiFontWeightSemiBold};
|
||||
padding-right: 16px;
|
||||
.smallDot {
|
||||
width: 3px !important;
|
||||
display: inline-block;
|
||||
}
|
||||
.euiBadge__text {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
export const TimelineKPIs = React.memo(({ kpis }: { kpis: TimelineKpiStrategyResponse | null }) => {
|
||||
const kpiFormat = '0,0.[000]a';
|
||||
const [defaultNumberFormat] = useUiSetting$<string>(DEFAULT_NUMBER_FORMAT);
|
||||
const formattedKpis = useMemo(() => {
|
||||
return {
|
||||
process: kpis === null ? getEmptyValue() : numeral(kpis.processCount).format(kpiFormat),
|
||||
user: kpis === null ? getEmptyValue() : numeral(kpis.userCount).format(kpiFormat),
|
||||
host: kpis === null ? getEmptyValue() : numeral(kpis.hostCount).format(kpiFormat),
|
||||
sourceIp: kpis === null ? getEmptyValue() : numeral(kpis.sourceIpCount).format(kpiFormat),
|
||||
destinationIp:
|
||||
kpis === null ? getEmptyValue() : numeral(kpis.destinationIpCount).format(kpiFormat),
|
||||
};
|
||||
}, [kpis]);
|
||||
|
||||
const formattedKpiToolTips = useMemo(() => {
|
||||
return {
|
||||
process: numeral(kpis?.processCount).format(defaultNumberFormat),
|
||||
user: numeral(kpis?.userCount).format(defaultNumberFormat),
|
||||
host: numeral(kpis?.hostCount).format(defaultNumberFormat),
|
||||
sourceIp: numeral(kpis?.sourceIpCount).format(defaultNumberFormat),
|
||||
destinationIp: numeral(kpis?.destinationIpCount).format(defaultNumberFormat),
|
||||
};
|
||||
}, [kpis, defaultNumberFormat]);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup wrap data-test-subj="siem-timeline-kpis">
|
||||
<EuiFlexItem grow={false}>
|
||||
<StatsContainer>
|
||||
{`${i18n.PROCESS_KPI_TITLE} : `}
|
||||
<EuiToolTip position="left" content={formattedKpiToolTips.process}>
|
||||
<EuiBadge color="hollow" data-test-subj={'siem-timeline-process-kpi'}>
|
||||
{formattedKpis.process}
|
||||
</EuiBadge>
|
||||
</EuiToolTip>
|
||||
</StatsContainer>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<StatsContainer>
|
||||
{`${i18n.USER_KPI_TITLE} : `}
|
||||
<EuiToolTip position="left" content={formattedKpiToolTips.user}>
|
||||
<EuiBadge color="hollow" data-test-subj={'siem-timeline-user-kpi'}>
|
||||
{formattedKpis.user}
|
||||
</EuiBadge>
|
||||
</EuiToolTip>
|
||||
</StatsContainer>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<StatsContainer>
|
||||
{`${i18n.HOST_KPI_TITLE} : `}
|
||||
<EuiToolTip position="left" content={formattedKpiToolTips.host}>
|
||||
<EuiBadge color="hollow" data-test-subj={'siem-timeline-host-kpi'}>
|
||||
{formattedKpis.host}
|
||||
</EuiBadge>
|
||||
</EuiToolTip>
|
||||
</StatsContainer>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<StatsContainer>
|
||||
{`${i18n.SOURCE_IP_KPI_TITLE} : `}
|
||||
<EuiToolTip position="left" content={formattedKpiToolTips.sourceIp}>
|
||||
<EuiBadge color="hollow" data-test-subj={'siem-timeline-source-ip-kpi'}>
|
||||
{formattedKpis.sourceIp}
|
||||
</EuiBadge>
|
||||
</EuiToolTip>
|
||||
</StatsContainer>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false} style={{ minWidth: 100 }}>
|
||||
<StatsContainer>
|
||||
{`${i18n.DESTINATION_IP_KPI_TITLE} : `}
|
||||
<EuiToolTip position="left" content={formattedKpiToolTips.destinationIp}>
|
||||
<EuiBadge color="hollow" data-test-subj={'siem-timeline-destination-ip-kpi'}>
|
||||
{formattedKpis.destinationIp}
|
||||
</EuiBadge>
|
||||
</EuiToolTip>
|
||||
</StatsContainer>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
});
|
||||
|
||||
TimelineKPIs.displayName = 'TimelineKPIs';
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
|
||||
export const PROCESS_KPI_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.timeline.kpis.processKpiTitle',
|
||||
{
|
||||
defaultMessage: 'Processes',
|
||||
}
|
||||
);
|
||||
|
||||
export const HOST_KPI_TITLE = i18n.translate('xpack.securitySolution.timeline.kpis.hostKpiTitle', {
|
||||
defaultMessage: 'Hosts',
|
||||
});
|
||||
|
||||
export const SOURCE_IP_KPI_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.timeline.kpis.sourceIpKpiTitle',
|
||||
{
|
||||
defaultMessage: 'Source IPs',
|
||||
}
|
||||
);
|
||||
|
||||
export const DESTINATION_IP_KPI_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.timeline.kpis.destinationKpiTitle',
|
||||
{
|
||||
defaultMessage: 'Destination IPs',
|
||||
}
|
||||
);
|
||||
|
||||
export const USER_KPI_TITLE = i18n.translate('xpack.securitySolution.timeline.kpis.userKpiTitle', {
|
||||
defaultMessage: 'Users',
|
||||
});
|
|
@ -21,6 +21,7 @@ import React, { Fragment, useCallback, useMemo, useState } from 'react';
|
|||
import { useDispatch } from 'react-redux';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import type { EuiTheme } from '@kbn/react-kibana-context-styled';
|
||||
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
|
||||
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
|
||||
import { timelineActions } from '../../../store/timeline';
|
||||
|
@ -51,6 +52,8 @@ const FullWidthFlexGroup = styled(EuiFlexGroup)`
|
|||
const ScrollableFlexItem = styled(EuiFlexItem)`
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding-inline: ${({ theme }) => (theme as EuiTheme).eui.euiSizeM};
|
||||
padding-block: ${({ theme }) => (theme as EuiTheme).eui.euiSizeS};
|
||||
`;
|
||||
|
||||
const VerticalRule = styled.div`
|
||||
|
@ -202,7 +205,6 @@ const NotesTabContentComponent: React.FC<NotesTabContentProps> = ({ timelineId }
|
|||
<>
|
||||
{createdBy && (
|
||||
<>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiTitle size="xs">
|
||||
<h4>{CREATED_BY}</h4>
|
||||
</EuiTitle>
|
||||
|
@ -218,13 +220,12 @@ const NotesTabContentComponent: React.FC<NotesTabContentProps> = ({ timelineId }
|
|||
);
|
||||
|
||||
return (
|
||||
<FullWidthFlexGroup>
|
||||
<FullWidthFlexGroup gutterSize="none">
|
||||
<ScrollableFlexItem grow={2} id="scrollableNotes">
|
||||
<StyledPanel paddingSize="s">
|
||||
<StyledPanel paddingSize="none">
|
||||
<EuiTitle>
|
||||
<h3>{NOTES}</h3>
|
||||
</EuiTitle>
|
||||
<EuiSpacer />
|
||||
<NotePreviews
|
||||
eventIdToNoteIds={eventIdToNoteIds}
|
||||
notes={notes}
|
||||
|
|
|
@ -44,7 +44,7 @@ describe('NewTimeline', () => {
|
|||
const mockGetButton = jest.fn().mockReturnValue('<></>');
|
||||
|
||||
const props: NewTimelineProps = {
|
||||
closeGearMenu: jest.fn(),
|
||||
onClick: jest.fn(),
|
||||
timelineId: 'mockTimelineId',
|
||||
title: 'mockTitle',
|
||||
};
|
||||
|
|
|
@ -27,9 +27,13 @@ NotesCountBadge.displayName = 'NotesCountBadge';
|
|||
|
||||
interface AddToFavoritesButtonProps {
|
||||
timelineId: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const AddToFavoritesButtonComponent: React.FC<AddToFavoritesButtonProps> = ({ timelineId }) => {
|
||||
const AddToFavoritesButtonComponent: React.FC<AddToFavoritesButtonProps> = ({
|
||||
timelineId,
|
||||
compact,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
|
||||
|
||||
|
@ -48,7 +52,19 @@ const AddToFavoritesButtonComponent: React.FC<AddToFavoritesButtonProps> = ({ ti
|
|||
[dispatch, timelineId, isFavorite]
|
||||
);
|
||||
|
||||
return (
|
||||
const label = isFavorite ? i18n.REMOVE_FROM_FAVORITES : i18n.ADD_TO_FAVORITES;
|
||||
|
||||
return compact ? (
|
||||
<EuiButtonIcon
|
||||
iconType={isFavorite ? 'starFilled' : 'starEmpty'}
|
||||
isSelected={isFavorite}
|
||||
onClick={handleClick}
|
||||
data-test-subj={`timeline-favorite-${isFavorite ? 'filled' : 'empty'}-star`}
|
||||
disabled={disableFavoriteButton}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
/>
|
||||
) : (
|
||||
<EuiButton
|
||||
isSelected={isFavorite}
|
||||
fill={isFavorite}
|
||||
|
@ -56,8 +72,10 @@ const AddToFavoritesButtonComponent: React.FC<AddToFavoritesButtonProps> = ({ ti
|
|||
onClick={handleClick}
|
||||
data-test-subj={`timeline-favorite-${isFavorite ? 'filled' : 'empty'}-star`}
|
||||
disabled={disableFavoriteButton}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
{isFavorite ? i18n.REMOVE_FROM_FAVORITES : i18n.ADD_TO_FAVORITES}
|
||||
{label}
|
||||
</EuiButton>
|
||||
);
|
||||
};
|
||||
|
@ -66,18 +84,18 @@ AddToFavoritesButtonComponent.displayName = 'AddToFavoritesButtonComponent';
|
|||
export const AddToFavoritesButton = React.memo(AddToFavoritesButtonComponent);
|
||||
|
||||
export interface NewTimelineProps {
|
||||
closeGearMenu?: () => void;
|
||||
onClick?: () => void;
|
||||
outline?: boolean;
|
||||
timelineId: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const NewTimeline = React.memo<NewTimelineProps>(
|
||||
({ closeGearMenu, outline = false, timelineId, title = i18n.NEW_TIMELINE }) => {
|
||||
({ onClick, outline = false, timelineId, title = i18n.NEW_TIMELINE }) => {
|
||||
const { getButton } = useCreateTimelineButton({
|
||||
timelineId,
|
||||
timelineType: TimelineType.default,
|
||||
closeGearMenu,
|
||||
onClick,
|
||||
});
|
||||
const button = getButton({ outline, title });
|
||||
|
||||
|
|
|
@ -55,7 +55,7 @@ describe('NewTemplateTimeline', () => {
|
|||
|
||||
wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<NewTemplateTimeline outline={true} closeGearMenu={mockClosePopover} title={mockTitle} />
|
||||
<NewTemplateTimeline outline={true} onClick={mockClosePopover} title={mockTitle} />
|
||||
</TestProviders>
|
||||
);
|
||||
});
|
||||
|
@ -92,7 +92,7 @@ describe('NewTemplateTimeline', () => {
|
|||
|
||||
wrapper = mount(
|
||||
<TestProviders store={store}>
|
||||
<NewTemplateTimeline outline={true} closeGearMenu={mockClosePopover} title={mockTitle} />
|
||||
<NewTemplateTimeline outline={true} onClick={mockClosePopover} title={mockTitle} />
|
||||
</TestProviders>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -13,14 +13,14 @@ import { TimelineType } from '../../../../../common/api/timeline';
|
|||
import { useCreateTimelineButton } from './use_create_timeline';
|
||||
|
||||
interface OwnProps {
|
||||
closeGearMenu?: () => void;
|
||||
onClick?: () => void;
|
||||
outline?: boolean;
|
||||
title?: string;
|
||||
timelineId?: string;
|
||||
}
|
||||
|
||||
export const NewTemplateTimelineComponent: React.FC<OwnProps> = ({
|
||||
closeGearMenu,
|
||||
onClick,
|
||||
outline,
|
||||
title,
|
||||
timelineId = TimelineId.active,
|
||||
|
@ -28,7 +28,7 @@ export const NewTemplateTimelineComponent: React.FC<OwnProps> = ({
|
|||
const { getButton } = useCreateTimelineButton({
|
||||
timelineId,
|
||||
timelineType: TimelineType.template,
|
||||
closeGearMenu,
|
||||
onClick,
|
||||
});
|
||||
|
||||
const button = getButton({ outline, title });
|
||||
|
|
|
@ -27,7 +27,7 @@ import { useDiscoverInTimelineContext } from '../../../../common/components/disc
|
|||
interface Props {
|
||||
timelineId?: string;
|
||||
timelineType: TimelineTypeLiteral;
|
||||
closeGearMenu?: () => void;
|
||||
onClick?: () => void;
|
||||
timeRange?: TimeRange;
|
||||
}
|
||||
|
||||
|
@ -35,7 +35,7 @@ interface Props {
|
|||
* Creates a new empty timeline at the given id.
|
||||
* Can be used to create new timelines or to reset timeline state.
|
||||
*/
|
||||
export const useCreateTimeline = ({ timelineId, timelineType, closeGearMenu }: Props) => {
|
||||
export const useCreateTimeline = ({ timelineId, timelineType, onClick }: Props) => {
|
||||
const dispatch = useDispatch();
|
||||
const defaultDataViewSelector = useMemo(() => sourcererSelectors.defaultDataViewSelector(), []);
|
||||
const { id: dataViewId, patternList: selectedPatterns } =
|
||||
|
@ -109,12 +109,12 @@ export const useCreateTimeline = ({ timelineId, timelineType, closeGearMenu }: P
|
|||
const handleCreateNewTimeline = useCallback(
|
||||
(options?: CreateNewTimelineOptions) => {
|
||||
createTimeline({ id: timelineId, show: true, timelineType, timeRange: options?.timeRange });
|
||||
if (typeof closeGearMenu === 'function') {
|
||||
closeGearMenu();
|
||||
if (typeof onClick === 'function') {
|
||||
onClick();
|
||||
}
|
||||
resetDiscoverAppState();
|
||||
},
|
||||
[createTimeline, timelineId, timelineType, closeGearMenu, resetDiscoverAppState]
|
||||
[createTimeline, timelineId, timelineType, onClick, resetDiscoverAppState]
|
||||
);
|
||||
|
||||
return handleCreateNewTimeline;
|
||||
|
@ -124,11 +124,11 @@ interface CreateNewTimelineOptions {
|
|||
timeRange?: TimeRange;
|
||||
}
|
||||
|
||||
export const useCreateTimelineButton = ({ timelineId, timelineType, closeGearMenu }: Props) => {
|
||||
export const useCreateTimelineButton = ({ timelineId, timelineType, onClick }: Props) => {
|
||||
const handleCreateNewTimeline = useCreateTimeline({
|
||||
timelineId,
|
||||
timelineType,
|
||||
closeGearMenu,
|
||||
onClick,
|
||||
});
|
||||
|
||||
const getButton = useCallback(
|
||||
|
|
|
@ -14,6 +14,7 @@ import deepEqual from 'fast-deep-equal';
|
|||
import type { Filter, Query } from '@kbn/es-query';
|
||||
import { FilterStateStore } from '@kbn/es-query';
|
||||
import type { FilterManager, SavedQuery, SavedQueryTimeFilter } from '@kbn/data-plugin/public';
|
||||
import styled from '@emotion/styled';
|
||||
import { InputsModelId } from '../../../../common/store/inputs/constants';
|
||||
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
|
||||
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
|
||||
|
@ -47,6 +48,42 @@ export interface QueryBarTimelineComponentProps {
|
|||
updateReduxTime: DispatchUpdateReduxTime;
|
||||
}
|
||||
|
||||
const SearchBarContainer = styled.div`
|
||||
/*
|
||||
*
|
||||
* hide search bar default filters as they are disturbing the layout as shown below
|
||||
*
|
||||
* Filters are displayed with QueryBar so below is how is the layout with default filters.
|
||||
*
|
||||
*
|
||||
* --------------------------------
|
||||
* -----------------| |------------
|
||||
* | DataViewPicker | QueryBar | Date |
|
||||
* -------------------------------------------------------------
|
||||
* | Filters |
|
||||
* --------------------------------
|
||||
*
|
||||
* The tree under this component makes sure that default filters are not rendered and we can separately display
|
||||
* them outside query component so that layout is as below:
|
||||
*
|
||||
* -----------------------------------------------------------
|
||||
* | DataViewPicker | QueryBar | Date |
|
||||
* -----------------------------------------------------------
|
||||
* | Filters |
|
||||
* -----------------------------------------------------------
|
||||
*
|
||||
* */
|
||||
.uniSearchBar .filter-items-group {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.euiDataGrid__restrictBody & {
|
||||
.kbnQueryBar {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const TIMELINE_FILTER_DROP_AREA = 'timeline-filter-drop-area';
|
||||
|
||||
const getNonDropAreaFilters = (filters: Filter[] = []) =>
|
||||
|
@ -265,22 +302,24 @@ export const QueryBarTimeline = memo<QueryBarTimelineComponentProps>(
|
|||
);
|
||||
|
||||
return (
|
||||
<QueryBar
|
||||
dateRangeFrom={dateRangeFrom}
|
||||
dateRangeTo={dateRangeTo}
|
||||
hideSavedQuery={kqlMode === 'search'}
|
||||
indexPattern={indexPattern}
|
||||
isRefreshPaused={isRefreshPaused}
|
||||
filterQuery={filterQueryConverted}
|
||||
filterManager={filterManager}
|
||||
filters={queryBarFilters}
|
||||
onSubmitQuery={onSubmitQuery}
|
||||
refreshInterval={refreshInterval}
|
||||
savedQuery={savedQuery}
|
||||
onSavedQuery={onSavedQuery}
|
||||
dataTestSubj={'timelineQueryInput'}
|
||||
displayStyle="inPage"
|
||||
/>
|
||||
<SearchBarContainer className="search_bar_container">
|
||||
<QueryBar
|
||||
dateRangeFrom={dateRangeFrom}
|
||||
dateRangeTo={dateRangeTo}
|
||||
hideSavedQuery={kqlMode === 'search'}
|
||||
indexPattern={indexPattern}
|
||||
isRefreshPaused={isRefreshPaused}
|
||||
filterQuery={filterQueryConverted}
|
||||
filterManager={filterManager}
|
||||
filters={queryBarFilters}
|
||||
onSubmitQuery={onSubmitQuery}
|
||||
refreshInterval={refreshInterval}
|
||||
savedQuery={savedQuery}
|
||||
onSavedQuery={onSavedQuery}
|
||||
dataTestSubj={'timelineQueryInput'}
|
||||
displayStyle="inPage"
|
||||
/>
|
||||
</SearchBarContainer>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -5,23 +5,23 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { shallow } from 'enzyme';
|
||||
import React from 'react';
|
||||
|
||||
import { coreMock } from '@kbn/core/public/mocks';
|
||||
import { mockIndexPattern } from '../../../../common/mock';
|
||||
import { TestProviders } from '../../../../common/mock/test_providers';
|
||||
import { mockIndexPattern } from '../../../../../common/mock';
|
||||
import { TestProviders } from '../../../../../common/mock/test_providers';
|
||||
import { FilterManager } from '@kbn/data-plugin/public';
|
||||
import { mockDataProviders } from '../data_providers/mock/mock_data_providers';
|
||||
import { useMountAppended } from '../../../../common/utils/use_mount_appended';
|
||||
import { mockDataProviders } from '../../data_providers/mock/mock_data_providers';
|
||||
import { useMountAppended } from '../../../../../common/utils/use_mount_appended';
|
||||
|
||||
import { TimelineHeader } from '.';
|
||||
import { TimelineStatus, TimelineType } from '../../../../../common/api/timeline';
|
||||
import { QueryTabHeader } from '.';
|
||||
import { TimelineStatus, TimelineType } from '../../../../../../common/api/timeline';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { TimelineId } from '../../../../../../common/types';
|
||||
|
||||
const mockUiSettingsForFilterManager = coreMock.createStart().uiSettings;
|
||||
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
jest.mock('../../../../../common/lib/kibana');
|
||||
|
||||
describe('Header', () => {
|
||||
const indexPattern = mockIndexPattern;
|
||||
|
@ -44,21 +44,16 @@ describe('Header', () => {
|
|||
show: true,
|
||||
showCallOutUnauthorizedMsg: false,
|
||||
status: TimelineStatus.active,
|
||||
timelineId: 'foo',
|
||||
timelineId: TimelineId.test,
|
||||
timelineType: TimelineType.default,
|
||||
};
|
||||
|
||||
describe('rendering', () => {
|
||||
test('renders correctly against snapshot', () => {
|
||||
const wrapper = shallow(<TimelineHeader {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('it renders the data providers when show is true', async () => {
|
||||
const testProps = { ...props, show: true };
|
||||
const wrapper = await getWrapper(
|
||||
<TestProviders>
|
||||
<TimelineHeader {...testProps} />
|
||||
<QueryTabHeader {...testProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
|
@ -74,7 +69,7 @@ describe('Header', () => {
|
|||
|
||||
const wrapper = await getWrapper(
|
||||
<TestProviders>
|
||||
<TimelineHeader {...testProps} />
|
||||
<QueryTabHeader {...testProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
|
@ -90,7 +85,7 @@ describe('Header', () => {
|
|||
|
||||
const wrapper = await getWrapper(
|
||||
<TestProviders>
|
||||
<TimelineHeader {...testProps} />
|
||||
<QueryTabHeader {...testProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
|
@ -108,7 +103,7 @@ describe('Header', () => {
|
|||
|
||||
const wrapper = await getWrapper(
|
||||
<TestProviders>
|
||||
<TimelineHeader {...testProps} />
|
||||
<QueryTabHeader {...testProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
|
@ -129,7 +124,7 @@ describe('Header', () => {
|
|||
|
||||
const wrapper = await getWrapper(
|
||||
<TestProviders>
|
||||
<TimelineHeader {...testProps} />
|
||||
<QueryTabHeader {...testProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
|
@ -146,7 +141,7 @@ describe('Header', () => {
|
|||
|
||||
const wrapper = await getWrapper(
|
||||
<TestProviders>
|
||||
<TimelineHeader {...testProps} />
|
||||
<QueryTabHeader {...testProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
|
@ -165,7 +160,7 @@ describe('Header', () => {
|
|||
|
||||
const wrapper = await getWrapper(
|
||||
<TestProviders>
|
||||
<TimelineHeader {...testProps} />
|
||||
<QueryTabHeader {...testProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
/*
|
||||
* 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 { EuiCallOut, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import React, { useMemo } from 'react';
|
||||
import type { FilterManager } from '@kbn/data-plugin/public';
|
||||
|
||||
import { IS_DRAGGING_CLASS_NAME } from '@kbn/securitysolution-t-grid';
|
||||
import styled from '@emotion/styled';
|
||||
import { euiThemeVars } from '@kbn/ui-theme';
|
||||
|
||||
import type { TimelineStatusLiteralWithNull } from '../../../../../../common/api/timeline';
|
||||
import { TimelineStatus, TimelineType } from '../../../../../../common/api/timeline';
|
||||
import { timelineSelectors } from '../../../../store/timeline';
|
||||
import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector';
|
||||
import { timelineDefaults } from '../../../../store/timeline/defaults';
|
||||
import * as i18n from './translations';
|
||||
import { StatefulSearchOrFilter } from '../../search_or_filter';
|
||||
import { DataProviders } from '../../data_providers';
|
||||
|
||||
interface Props {
|
||||
filterManager: FilterManager;
|
||||
show: boolean;
|
||||
showCallOutUnauthorizedMsg: boolean;
|
||||
status: TimelineStatusLiteralWithNull;
|
||||
timelineId: string;
|
||||
}
|
||||
|
||||
const DataProvidersContainer = styled.div<{ $shouldShowQueryBuilder: boolean }>`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
transition: 0.5s ease-in-out;
|
||||
overflow: hidden;
|
||||
|
||||
${(props) =>
|
||||
props.$shouldShowQueryBuilder
|
||||
? `display: block; max-height: 300px; visibility: visible; margin-block-start: 0px;`
|
||||
: `display: block; max-height: 0px; visibility: hidden; margin-block-start:-${euiThemeVars.euiSizeS};`}
|
||||
|
||||
.${IS_DRAGGING_CLASS_NAME} & {
|
||||
display: block;
|
||||
max-height: 300px;
|
||||
visibility: visible;
|
||||
margin-block-start: 0px;
|
||||
}
|
||||
`;
|
||||
|
||||
const QueryTabHeaderComponent: React.FC<Props> = ({
|
||||
filterManager,
|
||||
show,
|
||||
showCallOutUnauthorizedMsg,
|
||||
status,
|
||||
timelineId,
|
||||
}) => {
|
||||
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
|
||||
|
||||
const getIsDataProviderVisible = useMemo(
|
||||
() => timelineSelectors.dataProviderVisibilitySelector(),
|
||||
[]
|
||||
);
|
||||
|
||||
const timelineType = useDeepEqualSelector(
|
||||
(state) => (getTimeline(state, timelineId) ?? timelineDefaults).timelineType
|
||||
);
|
||||
|
||||
const isDataProviderVisible = useDeepEqualSelector(
|
||||
(state) => getIsDataProviderVisible(state, timelineId) ?? timelineDefaults.isDataProviderVisible
|
||||
);
|
||||
|
||||
const shouldShowQueryBuilder = isDataProviderVisible || timelineType === TimelineType.template;
|
||||
|
||||
return (
|
||||
<EuiFlexGroup gutterSize="s" direction="column">
|
||||
<EuiFlexItem>
|
||||
<StatefulSearchOrFilter filterManager={filterManager} timelineId={timelineId} />
|
||||
</EuiFlexItem>
|
||||
{showCallOutUnauthorizedMsg && (
|
||||
<EuiFlexItem>
|
||||
<EuiCallOut
|
||||
data-test-subj="timelineCallOutUnauthorized"
|
||||
title={i18n.CALL_OUT_UNAUTHORIZED_MSG}
|
||||
color="warning"
|
||||
iconType="warning"
|
||||
size="s"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
{status === TimelineStatus.immutable && (
|
||||
<EuiFlexItem>
|
||||
<EuiCallOut
|
||||
data-test-subj="timelineImmutableCallOut"
|
||||
title={i18n.CALL_OUT_IMMUTABLE}
|
||||
color="primary"
|
||||
iconType="warning"
|
||||
size="s"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
{show ? (
|
||||
<DataProvidersContainer
|
||||
className="data-providers-container"
|
||||
$shouldShowQueryBuilder={shouldShowQueryBuilder}
|
||||
>
|
||||
<DataProviders timelineId={timelineId} />
|
||||
</DataProvidersContainer>
|
||||
) : null}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export const QueryTabHeader = React.memo(QueryTabHeaderComponent);
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { timelineSelectors } from '../../../store/timeline';
|
||||
import { timelineSelectors } from '../../../../store/timeline';
|
||||
|
||||
export const getTimelineSaveModalByIdSelector = () =>
|
||||
createSelector(timelineSelectors.selectTimeline, (timeline) => ({
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
|
||||
export const CALL_OUT_UNAUTHORIZED_MSG = i18n.translate(
|
||||
'xpack.securitySolution.timeline.callOut.unauthorized.message.description',
|
||||
{
|
||||
defaultMessage:
|
||||
'You can use Timeline to investigate events, but you do not have the required permissions to save timelines for future use. If you need to save timelines, contact your Kibana administrator.',
|
||||
}
|
||||
);
|
||||
|
||||
export const CALL_OUT_IMMUTABLE = i18n.translate(
|
||||
'xpack.securitySolution.timeline.callOut.immutable.message.description',
|
||||
{
|
||||
defaultMessage:
|
||||
'This prebuilt timeline template cannot be modified. To make changes, please duplicate this template and make modifications to the duplicate template.',
|
||||
}
|
||||
);
|
|
@ -35,7 +35,7 @@ import { useKibana } from '../../../../common/lib/kibana';
|
|||
import { defaultHeaders } from '../body/column_headers/default_headers';
|
||||
import { StatefulBody } from '../body';
|
||||
import { Footer, footerHeight } from '../footer';
|
||||
import { TimelineHeader } from '../header';
|
||||
import { QueryTabHeader } from './header';
|
||||
import { calculateTotalPages } from '../helpers';
|
||||
import { combineQueries } from '../../../../common/lib/kuery';
|
||||
import { TimelineRefetch } from '../refetch_timeline';
|
||||
|
@ -46,7 +46,6 @@ import type {
|
|||
} from '../../../../../common/types/timeline';
|
||||
import { TimelineId, TimelineTabs } from '../../../../../common/types/timeline';
|
||||
import { requiredFieldsForActions } from '../../../../detections/components/alerts_table/default_config';
|
||||
import { SuperDatePicker } from '../../../../common/components/super_date_picker';
|
||||
import { EventDetailsWidthProvider } from '../../../../common/components/events_viewer/event_details_width_context';
|
||||
import type { inputsModel, State } from '../../../../common/store';
|
||||
import { inputsSelectors } from '../../../../common/store';
|
||||
|
@ -55,22 +54,19 @@ import { timelineDefaults } from '../../../store/timeline/defaults';
|
|||
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
|
||||
import { useTimelineEventsCountPortal } from '../../../../common/hooks/use_timeline_events_count';
|
||||
import type { TimelineModel } from '../../../store/timeline/model';
|
||||
import { TimelineDatePickerLock } from '../date_picker_lock';
|
||||
import { useTimelineFullScreen } from '../../../../common/containers/use_full_screen';
|
||||
import { activeTimeline } from '../../../containers/active_timeline_context';
|
||||
import { DetailsPanel } from '../../side_panel';
|
||||
import { ExitFullScreen } from '../../../../common/components/exit_full_screen';
|
||||
import { getDefaultControlColumn } from '../body/control_columns';
|
||||
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
|
||||
import { Sourcerer } from '../../../../common/components/sourcerer';
|
||||
import { useLicense } from '../../../../common/hooks/use_license';
|
||||
import { HeaderActions } from '../../../../common/components/header_actions/header_actions';
|
||||
const TimelineHeaderContainer = styled.div`
|
||||
margin-top: 6px;
|
||||
const QueryTabHeaderContainer = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
TimelineHeaderContainer.displayName = 'TimelineHeaderContainer';
|
||||
QueryTabHeaderContainer.displayName = 'TimelineHeaderContainer';
|
||||
|
||||
const StyledEuiFlyoutHeader = styled(EuiFlyoutHeader)`
|
||||
align-items: stretch;
|
||||
|
@ -79,8 +75,7 @@ const StyledEuiFlyoutHeader = styled(EuiFlyoutHeader)`
|
|||
flex-direction: column;
|
||||
|
||||
&.euiFlyoutHeader {
|
||||
${({ theme }) =>
|
||||
`padding: ${theme.eui.euiSizeM} ${theme.eui.euiSizeS} 0 ${theme.eui.euiSizeS};`}
|
||||
${({ theme }) => `padding: ${theme.eui.euiSizeS} 0 0 0;`}
|
||||
}
|
||||
`;
|
||||
|
||||
|
@ -320,10 +315,6 @@ export const QueryTabContentComponent: React.FC<Props> = ({
|
|||
);
|
||||
}, [loadingSourcerer, timelineId, isQueryLoading, dispatch]);
|
||||
|
||||
const isDatePickerDisabled = useMemo(() => {
|
||||
return (combinedQueries && combinedQueries.kqlError != null) || false;
|
||||
}, [combinedQueries]);
|
||||
|
||||
const leadingControlColumns = useMemo(
|
||||
() =>
|
||||
getDefaultControlColumn(ACTION_BUTTON_COUNT).map((x) => ({
|
||||
|
@ -352,45 +343,35 @@ export const QueryTabContentComponent: React.FC<Props> = ({
|
|||
data-test-subj={`${activeTab}-tab-flyout-header`}
|
||||
hasBorder={false}
|
||||
>
|
||||
<EuiFlexGroup
|
||||
alignItems="center"
|
||||
gutterSize="s"
|
||||
data-test-subj="timeline-date-picker-container"
|
||||
>
|
||||
<EuiFlexGroup gutterSize="s" direction="column">
|
||||
{timelineFullScreen && setTimelineFullScreen != null && (
|
||||
<ExitFullScreen
|
||||
fullScreen={timelineFullScreen}
|
||||
setFullScreen={setTimelineFullScreen}
|
||||
/>
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup alignItems="center" gutterSize="s">
|
||||
<ExitFullScreen
|
||||
fullScreen={timelineFullScreen}
|
||||
setFullScreen={setTimelineFullScreen}
|
||||
/>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem grow={10}>
|
||||
<SuperDatePicker
|
||||
width="auto"
|
||||
id={InputsModelId.timeline}
|
||||
timelineId={timelineId}
|
||||
disabled={isDatePickerDisabled}
|
||||
/>
|
||||
<EuiFlexItem data-test-subj="timeline-date-picker-container">
|
||||
<QueryTabHeaderContainer data-test-subj="timelineHeader">
|
||||
<QueryTabHeader
|
||||
filterManager={filterManager}
|
||||
show={show && activeTab === TimelineTabs.query}
|
||||
showCallOutUnauthorizedMsg={showCallOutUnauthorizedMsg}
|
||||
status={status}
|
||||
timelineId={timelineId}
|
||||
/>
|
||||
</QueryTabHeaderContainer>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<TimelineDatePickerLock />
|
||||
</EuiFlexItem>
|
||||
<SourcererFlex grow={1}>
|
||||
{activeTab === TimelineTabs.query && (
|
||||
<Sourcerer scope={SourcererScopeName.timeline} />
|
||||
)}
|
||||
</SourcererFlex>
|
||||
{/* TODO: This is a temporary solution to hide the KPIs until lens components play nicely with timelines */}
|
||||
{/* https://github.com/elastic/kibana/issues/17156 */}
|
||||
{/* <EuiFlexItem grow={false}> */}
|
||||
{/* <TimelineKpi timelineId={timelineId} /> */}
|
||||
{/* </EuiFlexItem> */}
|
||||
</EuiFlexGroup>
|
||||
<TimelineHeaderContainer data-test-subj="timelineHeader">
|
||||
<TimelineHeader
|
||||
filterManager={filterManager}
|
||||
show={show && activeTab === TimelineTabs.query}
|
||||
showCallOutUnauthorizedMsg={showCallOutUnauthorizedMsg}
|
||||
status={status}
|
||||
timelineId={timelineId}
|
||||
/>
|
||||
</TimelineHeaderContainer>
|
||||
</StyledEuiFlyoutHeader>
|
||||
|
||||
<EventDetailsWidthProvider>
|
||||
<StyledEuiFlyoutBody
|
||||
data-test-subj={`${TimelineTabs.query}-tab-flyout-body`}
|
||||
|
|
|
@ -6,14 +6,23 @@
|
|||
*/
|
||||
|
||||
import { getOr } from 'lodash/fp';
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useState, useEffect, useMemo } from 'react';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import { connect } from 'react-redux';
|
||||
import { useDispatch, connect } from 'react-redux';
|
||||
import type { Dispatch } from 'redux';
|
||||
import deepEqual from 'fast-deep-equal';
|
||||
import type { Filter } from '@kbn/es-query';
|
||||
|
||||
import type { FilterManager } from '@kbn/data-plugin/public';
|
||||
import type { DataView } from '@kbn/data-views-plugin/common';
|
||||
import { FilterItems } from '@kbn/unified-search-plugin/public';
|
||||
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import styled from 'styled-components';
|
||||
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
|
||||
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
|
||||
import { useKibana } from '../../../../common/lib/kibana';
|
||||
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
|
||||
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
|
||||
import type { State, inputsModel } from '../../../../common/store';
|
||||
import { inputsSelectors } from '../../../../common/store';
|
||||
import { timelineActions, timelineSelectors } from '../../../store/timeline';
|
||||
|
@ -21,6 +30,10 @@ import type { KqlMode, TimelineModel } from '../../../store/timeline/model';
|
|||
import { timelineDefaults } from '../../../store/timeline/defaults';
|
||||
import { dispatchUpdateReduxTime } from '../../../../common/components/super_date_picker';
|
||||
import { SearchOrFilter } from './search_or_filter';
|
||||
import { setDataProviderVisibility } from '../../../store/timeline/actions';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const FilterItemsContainer = styled(EuiFlexGroup)``;
|
||||
|
||||
interface OwnProps {
|
||||
filterManager: FilterManager;
|
||||
|
@ -29,6 +42,9 @@ interface OwnProps {
|
|||
|
||||
type Props = OwnProps & PropsFromRedux;
|
||||
|
||||
export const isDataView = (obj: unknown): obj is DataView =>
|
||||
obj != null && typeof obj === 'object' && Object.hasOwn(obj, 'getName');
|
||||
|
||||
const StatefulSearchOrFilterComponent = React.memo<Props>(
|
||||
({
|
||||
dataProviders,
|
||||
|
@ -48,7 +64,59 @@ const StatefulSearchOrFilterComponent = React.memo<Props>(
|
|||
toStr,
|
||||
updateKqlMode,
|
||||
updateReduxTime,
|
||||
timelineType,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { addError } = useAppToasts();
|
||||
|
||||
const [dataView, setDataView] = useState<DataView>();
|
||||
const {
|
||||
services: { data },
|
||||
} = useKibana();
|
||||
|
||||
const { indexPattern } = useSourcererDataView(SourcererScopeName.timeline);
|
||||
|
||||
const getIsDataProviderVisible = useMemo(
|
||||
() => timelineSelectors.dataProviderVisibilitySelector(),
|
||||
[]
|
||||
);
|
||||
|
||||
const isDataProviderVisible = useDeepEqualSelector((state) =>
|
||||
getIsDataProviderVisible(state, timelineId)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let dv: DataView;
|
||||
if (isDataView(indexPattern)) {
|
||||
setDataView(indexPattern);
|
||||
} else if (!filterQuery) {
|
||||
const createDataView = async () => {
|
||||
try {
|
||||
dv = await data.dataViews.create({ title: indexPattern.title });
|
||||
setDataView(dv);
|
||||
} catch (error) {
|
||||
addError(error, { title: i18n.ERROR_PROCESSING_INDEX_PATTERNS });
|
||||
}
|
||||
};
|
||||
createDataView();
|
||||
}
|
||||
return () => {
|
||||
if (dv?.id) {
|
||||
data.dataViews.clearInstanceCache(dv?.id);
|
||||
}
|
||||
};
|
||||
}, [data.dataViews, indexPattern, filterQuery, addError]);
|
||||
|
||||
const arrDataView = useMemo(() => (dataView != null ? [dataView] : []), [dataView]);
|
||||
|
||||
const onFiltersUpdated = useCallback(
|
||||
(newFilters: Filter[]) => {
|
||||
filterManager.setFilters(newFilters);
|
||||
},
|
||||
[filterManager]
|
||||
);
|
||||
|
||||
const setFiltersInTimeline = useCallback(
|
||||
(newFilters: Filter[]) =>
|
||||
setFilters({
|
||||
|
@ -67,26 +135,83 @@ const StatefulSearchOrFilterComponent = React.memo<Props>(
|
|||
[timelineId, setSavedQueryId]
|
||||
);
|
||||
|
||||
const toggleDataProviderVisibility = useCallback(() => {
|
||||
dispatch(
|
||||
setDataProviderVisibility({ id: timelineId, isDataProviderVisible: !isDataProviderVisible })
|
||||
);
|
||||
}, [isDataProviderVisible, timelineId, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
/*
|
||||
* If there is a change in data providers
|
||||
* - data provider has some data and it was hidden,
|
||||
* * it must be made visible
|
||||
*
|
||||
* - data provider has no data and it was visible,
|
||||
* * it must be hidden
|
||||
*
|
||||
* */
|
||||
if (dataProviders?.length > 0) {
|
||||
dispatch(setDataProviderVisibility({ id: timelineId, isDataProviderVisible: true }));
|
||||
} else if (dataProviders?.length === 0) {
|
||||
dispatch(setDataProviderVisibility({ id: timelineId, isDataProviderVisible: false }));
|
||||
}
|
||||
}, [dataProviders, dispatch, timelineId]);
|
||||
|
||||
return (
|
||||
<SearchOrFilter
|
||||
dataProviders={dataProviders}
|
||||
filters={filters}
|
||||
filterManager={filterManager}
|
||||
filterQuery={filterQuery}
|
||||
from={from}
|
||||
fromStr={fromStr}
|
||||
isRefreshPaused={isRefreshPaused}
|
||||
kqlMode={kqlMode}
|
||||
refreshInterval={refreshInterval}
|
||||
savedQueryId={savedQueryId}
|
||||
setFilters={setFiltersInTimeline}
|
||||
setSavedQueryId={setSavedQueryInTimeline}
|
||||
timelineId={timelineId}
|
||||
to={to}
|
||||
toStr={toStr}
|
||||
updateKqlMode={updateKqlMode}
|
||||
updateReduxTime={updateReduxTime}
|
||||
/>
|
||||
<EuiFlexGroup direction="column" gutterSize="s">
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup
|
||||
className="eui-scrollBar"
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gutterSize="xs"
|
||||
responsive={false}
|
||||
>
|
||||
<EuiFlexItem grow={true}>
|
||||
<SearchOrFilter
|
||||
dataProviders={dataProviders}
|
||||
filters={filters}
|
||||
filterManager={filterManager}
|
||||
filterQuery={filterQuery}
|
||||
from={from}
|
||||
fromStr={fromStr}
|
||||
isRefreshPaused={isRefreshPaused}
|
||||
kqlMode={kqlMode}
|
||||
refreshInterval={refreshInterval}
|
||||
savedQueryId={savedQueryId}
|
||||
setFilters={setFiltersInTimeline}
|
||||
setSavedQueryId={setSavedQueryInTimeline}
|
||||
timelineId={timelineId}
|
||||
to={to}
|
||||
toStr={toStr}
|
||||
updateKqlMode={updateKqlMode}
|
||||
updateReduxTime={updateReduxTime}
|
||||
toggleDataProviderVisibility={toggleDataProviderVisibility}
|
||||
isDataProviderVisible={isDataProviderVisible}
|
||||
timelineType={timelineType}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
{filters && filters.length > 0 ? (
|
||||
<EuiFlexItem>
|
||||
<FilterItemsContainer
|
||||
data-test-subj="timeline-filters-container"
|
||||
direction="row"
|
||||
gutterSize="xs"
|
||||
wrap={true}
|
||||
responsive={false}
|
||||
>
|
||||
<FilterItems
|
||||
filters={filters}
|
||||
onFiltersUpdated={onFiltersUpdated}
|
||||
indexPatterns={arrDataView}
|
||||
/>
|
||||
</FilterItemsContainer>
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
|
@ -104,7 +229,8 @@ const StatefulSearchOrFilterComponent = React.memo<Props>(
|
|||
deepEqual(prevProps.filterQuery, nextProps.filterQuery) &&
|
||||
deepEqual(prevProps.kqlMode, nextProps.kqlMode) &&
|
||||
deepEqual(prevProps.savedQueryId, nextProps.savedQueryId) &&
|
||||
deepEqual(prevProps.timelineId, nextProps.timelineId)
|
||||
deepEqual(prevProps.timelineId, nextProps.timelineId) &&
|
||||
prevProps.timelineType === nextProps.timelineType
|
||||
);
|
||||
}
|
||||
);
|
||||
|
@ -135,8 +261,10 @@ const makeMapStateToProps = () => {
|
|||
to: input.timerange.to,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
toStr: input.timerange.toStr!,
|
||||
timelineType: timeline.timelineType,
|
||||
};
|
||||
};
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
|
|
|
@ -5,40 +5,29 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui';
|
||||
import React, { useCallback } from 'react';
|
||||
import styled, { createGlobalStyle } from 'styled-components';
|
||||
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui';
|
||||
import React, { useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import type { Filter } from '@kbn/es-query';
|
||||
|
||||
import type { FilterManager } from '@kbn/data-plugin/public';
|
||||
import { TimelineType } from '../../../../../common/api/timeline';
|
||||
import { InputsModelId } from '../../../../common/store/inputs/constants';
|
||||
import type { KqlMode } from '../../../store/timeline/model';
|
||||
import type { DispatchUpdateReduxTime } from '../../../../common/components/super_date_picker';
|
||||
import { SuperDatePicker } from '../../../../common/components/super_date_picker';
|
||||
import type { KueryFilterQuery } from '../../../../../common/types/timeline';
|
||||
import type { DataProvider } from '../data_providers/data_provider';
|
||||
import { QueryBarTimeline } from '../query_bar';
|
||||
|
||||
import { EuiSuperSelect } from './super_select';
|
||||
import { options } from './helpers';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const timelineSelectModeItemsClassName = 'timelineSelectModeItemsClassName';
|
||||
const searchOrFilterPopoverClassName = 'searchOrFilterPopover';
|
||||
const searchOrFilterPopoverWidth = '352px';
|
||||
|
||||
// SIDE EFFECT: the following creates a global class selector
|
||||
const SearchOrFilterGlobalStyle = createGlobalStyle`
|
||||
.${timelineSelectModeItemsClassName} {
|
||||
width: 350px !important;
|
||||
}
|
||||
|
||||
.${searchOrFilterPopoverClassName}.euiPopover__panel {
|
||||
width: ${searchOrFilterPopoverWidth} !important;
|
||||
|
||||
.euiSuperSelect__listbox {
|
||||
width: ${searchOrFilterPopoverWidth} !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
import { TimelineDatePickerLock } from '../date_picker_lock';
|
||||
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
|
||||
import { Sourcerer } from '../../../../common/components/sourcerer';
|
||||
import {
|
||||
DATA_PROVIDER_HIDDEN_EMPTY,
|
||||
DATA_PROVIDER_HIDDEN_POPULATED,
|
||||
DATA_PROVIDER_VISIBLE,
|
||||
} from './translations';
|
||||
|
||||
interface Props {
|
||||
dataProviders: DataProvider[];
|
||||
|
@ -58,11 +47,14 @@ interface Props {
|
|||
to: string;
|
||||
toStr: string;
|
||||
updateReduxTime: DispatchUpdateReduxTime;
|
||||
isDataProviderVisible: boolean;
|
||||
toggleDataProviderVisibility: () => void;
|
||||
timelineType: TimelineType;
|
||||
}
|
||||
|
||||
const SearchOrFilterContainer = styled.div`
|
||||
${({ theme }) => `margin-top: ${theme.eui.euiSizeXS};`}
|
||||
user-select: none; // This should not be here, it makes the entire page inaccessible
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
`;
|
||||
|
||||
SearchOrFilterContainer.displayName = 'SearchOrFilterContainer';
|
||||
|
@ -90,33 +82,41 @@ export const SearchOrFilter = React.memo<Props>(
|
|||
setSavedQueryId,
|
||||
to,
|
||||
toStr,
|
||||
updateKqlMode,
|
||||
updateReduxTime,
|
||||
isDataProviderVisible,
|
||||
toggleDataProviderVisibility,
|
||||
timelineType,
|
||||
}) => {
|
||||
const handleChange = useCallback(
|
||||
(mode: KqlMode) => updateKqlMode({ id: timelineId, kqlMode: mode }),
|
||||
[timelineId, updateKqlMode]
|
||||
const isDataProviderEmpty = useMemo(() => dataProviders?.length === 0, [dataProviders]);
|
||||
|
||||
const dataProviderIconTooltipContent = useMemo(() => {
|
||||
if (isDataProviderVisible) {
|
||||
return DATA_PROVIDER_VISIBLE;
|
||||
}
|
||||
if (isDataProviderEmpty) {
|
||||
return DATA_PROVIDER_HIDDEN_EMPTY;
|
||||
}
|
||||
return DATA_PROVIDER_HIDDEN_POPULATED;
|
||||
}, [isDataProviderEmpty, isDataProviderVisible]);
|
||||
|
||||
const buttonColor = useMemo(
|
||||
() => (isDataProviderEmpty || isDataProviderVisible ? 'primary' : 'warning'),
|
||||
[isDataProviderEmpty, isDataProviderVisible]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchOrFilterContainer>
|
||||
<EuiFlexGroup data-test-subj="timeline-search-or-filter" gutterSize="xs">
|
||||
<ModeFlexItem grow={false}>
|
||||
<EuiToolTip content={i18n.FILTER_OR_SEARCH_WITH_KQL}>
|
||||
<EuiSuperSelect
|
||||
data-test-subj="timeline-select-search-or-filter"
|
||||
hasDividers={true}
|
||||
itemLayoutAlign="top"
|
||||
itemClassName={timelineSelectModeItemsClassName}
|
||||
onChange={handleChange}
|
||||
options={options}
|
||||
popoverProps={{ className: searchOrFilterPopoverClassName }}
|
||||
valueOfSelected={kqlMode}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
</ModeFlexItem>
|
||||
<EuiFlexItem data-test-subj="timeline-search-or-filter-search-container">
|
||||
<EuiFlexGroup
|
||||
data-test-subj="timeline-search-or-filter"
|
||||
gutterSize="xs"
|
||||
alignItems="flexStart"
|
||||
responsive={false}
|
||||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
<Sourcerer scope={SourcererScopeName.timeline} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem data-test-subj="timeline-search-or-filter-search-container" grow={1}>
|
||||
<QueryBarTimeline
|
||||
dataProviders={dataProviders}
|
||||
filters={filters}
|
||||
|
@ -136,9 +136,42 @@ export const SearchOrFilter = React.memo<Props>(
|
|||
updateReduxTime={updateReduxTime}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
{
|
||||
/*
|
||||
DataProvider toggle is not needed in template timeline because
|
||||
it is always visible
|
||||
*/
|
||||
timelineType === TimelineType.default ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiToolTip content={dataProviderIconTooltipContent}>
|
||||
<EuiButtonIcon
|
||||
color={buttonColor}
|
||||
isSelected={isDataProviderVisible}
|
||||
iconType="timeline"
|
||||
data-test-subj="toggle-data-provider"
|
||||
size="m"
|
||||
display="base"
|
||||
aria-label={dataProviderIconTooltipContent}
|
||||
onClick={toggleDataProviderVisibility}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
) : null
|
||||
}
|
||||
<EuiFlexItem grow={false}>
|
||||
<TimelineDatePickerLock />
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={false} data-test-subj="timeline-date-picker-container">
|
||||
<SuperDatePicker
|
||||
width="auto"
|
||||
id={InputsModelId.timeline}
|
||||
timelineId={timelineId}
|
||||
disabled={false}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</SearchOrFilterContainer>
|
||||
<SearchOrFilterGlobalStyle />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -64,9 +64,30 @@ export const SEARCH_KQL_SELECTED_TEXT = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
export const FILTER_OR_SEARCH_WITH_KQL = i18n.translate(
|
||||
'xpack.securitySolution.timeline.searchOrFilter.filterOrSearchWithKql',
|
||||
export const DATA_PROVIDER_HIDDEN_POPULATED = i18n.translate(
|
||||
'xpack.securitySolution.timeline.searchOrFilter.dataProviderToggle.hiddenAndPopulated',
|
||||
{
|
||||
defaultMessage: 'Filter or Search with KQL',
|
||||
defaultMessage: 'Query Builder is hidden. Click here to see the existing Queries',
|
||||
}
|
||||
);
|
||||
|
||||
export const DATA_PROVIDER_VISIBLE = i18n.translate(
|
||||
'xpack.securitySolution.timeline.searchOrFilter.dataProviderToggle.visible',
|
||||
{
|
||||
defaultMessage: 'Click here to hide Query builder',
|
||||
}
|
||||
);
|
||||
|
||||
export const DATA_PROVIDER_HIDDEN_EMPTY = i18n.translate(
|
||||
'xpack.securitySolution.timeline.searchOrFilter.dataProviderToggle.hiddenAndEmpty',
|
||||
{
|
||||
defaultMessage: 'Click here to show the empty Query builder',
|
||||
}
|
||||
);
|
||||
|
||||
export const ERROR_PROCESSING_INDEX_PATTERNS = i18n.translate(
|
||||
'xpack.securitySolution.timeline.searchOrFilter.errorProcessingDataView',
|
||||
{
|
||||
defaultMessage: 'Error processing Index Patterns',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -276,6 +276,10 @@ const StyledEuiTab = styled(EuiTab)`
|
|||
}
|
||||
`;
|
||||
|
||||
const StyledEuiTabs = styled(EuiTabs)`
|
||||
padding-inline: ${(props) => props.theme.eui.euiSizeM};
|
||||
`;
|
||||
|
||||
const TabsContentComponent: React.FC<BasicTimelineTab> = ({
|
||||
renderCellValue,
|
||||
rowRenderers,
|
||||
|
@ -389,7 +393,7 @@ const TabsContentComponent: React.FC<BasicTimelineTab> = ({
|
|||
return (
|
||||
<>
|
||||
{!timelineFullScreen && (
|
||||
<EuiTabs>
|
||||
<StyledEuiTabs className="eui-scrollBar">
|
||||
<StyledEuiTab
|
||||
data-test-subj={`timelineTabs-${TimelineTabs.query}`}
|
||||
onClick={setQueryAsActiveTab}
|
||||
|
@ -493,7 +497,7 @@ const TabsContentComponent: React.FC<BasicTimelineTab> = ({
|
|||
<span>{i18n.SECURITY_ASSISTANT}</span>
|
||||
</StyledEuiTab>
|
||||
)}
|
||||
</EuiTabs>
|
||||
</StyledEuiTabs>
|
||||
)}
|
||||
|
||||
<ActiveTimelineTab
|
||||
|
|
|
@ -273,4 +273,9 @@ export const setIsDiscoverSavedSearchLoaded = actionCreator<{
|
|||
isDiscoverSavedSearchLoaded: boolean;
|
||||
}>('SET_IS_DISCOVER_SAVED_SEARCH_LOADED');
|
||||
|
||||
export const setDataProviderVisibility = actionCreator<{
|
||||
id: string;
|
||||
isDataProviderVisible: boolean;
|
||||
}>('SET_DATA_PROVIDER_VISIBLITY');
|
||||
|
||||
export const setChanged = actionCreator<{ id: string; changed: boolean }>('SET_CHANGED');
|
||||
|
|
|
@ -80,6 +80,7 @@ export const timelineDefaults: SubsetTimelineModel &
|
|||
filters: [],
|
||||
savedSearchId: null,
|
||||
isDiscoverSavedSearchLoaded: false,
|
||||
isDataProviderVisible: false,
|
||||
};
|
||||
|
||||
export const getTimelineManageDefaults = (id: string) => ({
|
||||
|
|
|
@ -176,6 +176,7 @@ describe('Epic Timeline', () => {
|
|||
id: '11169110-fc22-11e9-8ca9-072f15ce2685',
|
||||
savedQueryId: 'my endgame timeline query',
|
||||
savedSearchId: null,
|
||||
isDataProviderVisible: true,
|
||||
};
|
||||
|
||||
expect(
|
||||
|
|
|
@ -138,6 +138,7 @@ const basicTimeline: TimelineModel = {
|
|||
title: '',
|
||||
version: null,
|
||||
savedSearchId: null,
|
||||
isDataProviderVisible: true,
|
||||
};
|
||||
const timelineByIdMock: TimelineById = {
|
||||
foo: { ...basicTimeline },
|
|
@ -136,6 +136,7 @@ export interface TimelineModel {
|
|||
/* discover saved search Id */
|
||||
savedSearchId: string | null;
|
||||
isDiscoverSavedSearchLoaded?: boolean;
|
||||
isDataProviderVisible: boolean;
|
||||
/** used to mark the timeline as unsaved in the UI */
|
||||
changed?: boolean;
|
||||
}
|
||||
|
@ -193,6 +194,7 @@ export type SubsetTimelineModel = Readonly<
|
|||
| 'filterManager'
|
||||
| 'savedSearchId'
|
||||
| 'isDiscoverSavedSearchLoaded'
|
||||
| 'isDataProviderVisible'
|
||||
| 'changed'
|
||||
>
|
||||
>;
|
||||
|
|
|
@ -59,6 +59,7 @@ import {
|
|||
clearEventsLoading,
|
||||
updateSavedSearchId,
|
||||
setIsDiscoverSavedSearchLoaded,
|
||||
setDataProviderVisibility,
|
||||
setChanged,
|
||||
} from './actions';
|
||||
|
||||
|
@ -530,6 +531,18 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState)
|
|||
},
|
||||
},
|
||||
}))
|
||||
.case(setDataProviderVisibility, (state, { id, isDataProviderVisible }) => {
|
||||
return {
|
||||
...state,
|
||||
timelineById: {
|
||||
...state.timelineById,
|
||||
[id]: {
|
||||
...state.timelineById[id],
|
||||
isDataProviderVisible,
|
||||
},
|
||||
},
|
||||
};
|
||||
})
|
||||
.case(setChanged, (state, { id, changed }) => ({
|
||||
...state,
|
||||
timelineById: {
|
||||
|
|
|
@ -54,3 +54,6 @@ export const getKqlFilterKuerySelector = () =>
|
|||
? timeline.kqlQuery.filterQuery.kuery
|
||||
: null
|
||||
);
|
||||
|
||||
export const dataProviderVisibilitySelector = () =>
|
||||
createSelector(selectTimeline, (timeline) => timeline.isDataProviderVisible);
|
||||
|
|
|
@ -15,7 +15,11 @@
|
|||
"public/**/*.json",
|
||||
"../../../typings/**/*"
|
||||
],
|
||||
"exclude": ["target/**/*", "**/cypress/**", "public/management/cypress.config.ts"],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
"**/cypress/**",
|
||||
"public/management/cypress.config.ts"
|
||||
],
|
||||
"kbn_references": [
|
||||
"@kbn/core",
|
||||
{
|
||||
|
@ -174,6 +178,7 @@
|
|||
"@kbn/openapi-generator",
|
||||
"@kbn/es",
|
||||
"@kbn/react-kibana-mount",
|
||||
"@kbn/react-kibana-context-styled",
|
||||
"@kbn/unified-doc-viewer-plugin",
|
||||
"@kbn/shared-ux-error-boundary",
|
||||
"@kbn/zod-helpers",
|
||||
|
|
|
@ -36677,7 +36677,7 @@
|
|||
"xpack.securitySolution.timeline.saveTimeline.modal.header": "Enregistrer la chronologie",
|
||||
"xpack.securitySolution.timeline.saveTimeline.modal.optionalLabel": "Facultatif",
|
||||
"xpack.securitySolution.timeline.saveTimeline.modal.titleAriaLabel": "Titre",
|
||||
"xpack.securitySolution.timeline.saveTimeline.modal.titleTitle": "Titre",
|
||||
"xpack.securitySolution.timeline.saveTimeline.modal.title": "Titre",
|
||||
"xpack.securitySolution.timeline.saveTimelineTemplate.modal.discard.title": "Abandonner le modèle de chronologie",
|
||||
"xpack.securitySolution.timeline.saveTimelineTemplate.modal.header": "Enregistrer le modèle de chronologie",
|
||||
"xpack.securitySolution.timeline.searchOrFilter.filterDescription": "Les événements des fournisseurs de données ci-dessus sont filtrés par le KQL adjacent",
|
||||
|
|
|
@ -36675,7 +36675,7 @@
|
|||
"xpack.securitySolution.timeline.saveTimeline.modal.header": "タイムラインを保存",
|
||||
"xpack.securitySolution.timeline.saveTimeline.modal.optionalLabel": "オプション",
|
||||
"xpack.securitySolution.timeline.saveTimeline.modal.titleAriaLabel": "タイトル",
|
||||
"xpack.securitySolution.timeline.saveTimeline.modal.titleTitle": "タイトル",
|
||||
"xpack.securitySolution.timeline.saveTimeline.modal.title": "タイトル",
|
||||
"xpack.securitySolution.timeline.saveTimelineTemplate.modal.discard.title": "タイムラインテンプレートを破棄",
|
||||
"xpack.securitySolution.timeline.saveTimelineTemplate.modal.header": "タイムラインテンプレートを保存",
|
||||
"xpack.securitySolution.timeline.searchOrFilter.filterDescription": "上のデータプロバイダーからのイベントは、隣接の KQL でフィルターされます",
|
||||
|
|
|
@ -36671,7 +36671,7 @@
|
|||
"xpack.securitySolution.timeline.saveTimeline.modal.header": "保存时间线",
|
||||
"xpack.securitySolution.timeline.saveTimeline.modal.optionalLabel": "可选",
|
||||
"xpack.securitySolution.timeline.saveTimeline.modal.titleAriaLabel": "标题",
|
||||
"xpack.securitySolution.timeline.saveTimeline.modal.titleTitle": "标题",
|
||||
"xpack.securitySolution.timeline.saveTimeline.modal.title": "标题",
|
||||
"xpack.securitySolution.timeline.saveTimelineTemplate.modal.discard.title": "丢弃时间线模板",
|
||||
"xpack.securitySolution.timeline.saveTimelineTemplate.modal.header": "保存时间线模板",
|
||||
"xpack.securitySolution.timeline.searchOrFilter.filterDescription": "上述数据提供程序的事件按相邻 KQL 进行筛选",
|
||||
|
|
|
@ -34,7 +34,7 @@ import {
|
|||
CASES_METRIC,
|
||||
UNEXPECTED_METRICS,
|
||||
} from '../../../screens/case_details';
|
||||
import { TIMELINE_DESCRIPTION, TIMELINE_QUERY, TIMELINE_TITLE } from '../../../screens/timeline';
|
||||
import { TIMELINE_QUERY, TIMELINE_TITLE } from '../../../screens/timeline';
|
||||
|
||||
import { OVERVIEW_CASE_DESCRIPTION, OVERVIEW_CASE_NAME } from '../../../screens/overview';
|
||||
|
||||
|
@ -123,7 +123,6 @@ describe('Cases', { tags: ['@ess', '@serverless'] }, () => {
|
|||
openCaseTimeline();
|
||||
|
||||
cy.get(TIMELINE_TITLE).contains(this.mycase.timeline.title);
|
||||
cy.get(TIMELINE_DESCRIPTION).contains(this.mycase.timeline.description);
|
||||
cy.get(TIMELINE_QUERY).should('have.text', this.mycase.timeline.query);
|
||||
|
||||
visitWithTimeRange(OVERVIEW_URL);
|
||||
|
|
|
@ -7,8 +7,12 @@
|
|||
|
||||
import { disableExpandableFlyout } from '../../../tasks/api_calls/kibana_advanced_settings';
|
||||
import { getNewRule } from '../../../objects/rule';
|
||||
import { PROVIDER_BADGE, QUERY_TAB_BUTTON, TIMELINE_TITLE } from '../../../screens/timeline';
|
||||
import { FILTER_BADGE } from '../../../screens/alerts';
|
||||
import {
|
||||
PROVIDER_BADGE,
|
||||
QUERY_TAB_BUTTON,
|
||||
TIMELINE_FILTER_BADGE,
|
||||
TIMELINE_TITLE,
|
||||
} from '../../../screens/timeline';
|
||||
|
||||
import { expandFirstAlert, investigateFirstAlertInTimeline } from '../../../tasks/alerts';
|
||||
import { createRule } from '../../../tasks/api_calls/rules';
|
||||
|
@ -80,7 +84,7 @@ describe('Investigate in timeline', { tags: ['@ess', '@serverless'] }, () => {
|
|||
cy.get(QUERY_TAB_BUTTON).should('contain.text', alertCount);
|
||||
|
||||
// The correct filter is applied to the timeline query
|
||||
cy.get(FILTER_BADGE).should(
|
||||
cy.get(TIMELINE_FILTER_BADGE).should(
|
||||
'have.text',
|
||||
' {"bool":{"must":[{"term":{"process.args":"-zsh"}},{"term":{"process.args":"unique"}}]}}'
|
||||
);
|
||||
|
|
|
@ -111,7 +111,6 @@ describe('Timeline Templates', { tags: ['@ess', '@serverless'] }, () => {
|
|||
addNameToTimelineAndSave('Test');
|
||||
cy.wait('@timeline', { timeout: 100000 });
|
||||
cy.get(TIMELINE_FLYOUT_WRAPPER).should('have.css', 'visibility', 'visible');
|
||||
cy.get(TIMELINE_DESCRIPTION).should('have.text', getTimeline().description);
|
||||
cy.get(TIMELINE_QUERY).should('have.text', getTimeline().query);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -12,15 +12,14 @@ import {
|
|||
LOCKED_ICON,
|
||||
NOTES_TEXT,
|
||||
PIN_EVENT,
|
||||
TIMELINE_DESCRIPTION,
|
||||
TIMELINE_FILTER,
|
||||
TIMELINE_FLYOUT_WRAPPER,
|
||||
TIMELINE_QUERY,
|
||||
TIMELINE_PANEL,
|
||||
TIMELINE_STATUS,
|
||||
TIMELINE_TAB_CONTENT_GRAPHS_NOTES,
|
||||
TIMELINE_SAVE_MODAL_OPEN_BUTTON,
|
||||
SAVE_TIMELINE_BTN_TOOLTIP,
|
||||
SAVE_TIMELINE_ACTION_BTN,
|
||||
SAVE_TIMELINE_TOOLTIP,
|
||||
} from '../../../screens/timeline';
|
||||
import { createTimelineTemplate } from '../../../tasks/api_calls/timelines';
|
||||
|
||||
|
@ -62,9 +61,7 @@ describe('Create a timeline from a template', { tags: ['@ess', '@serverless'] },
|
|||
selectCustomTemplates();
|
||||
expandEventAction();
|
||||
clickingOnCreateTimelineFormTemplateBtn();
|
||||
|
||||
cy.get(TIMELINE_FLYOUT_WRAPPER).should('have.css', 'visibility', 'visible');
|
||||
cy.get(TIMELINE_DESCRIPTION).should('have.text', getTimeline().description);
|
||||
cy.get(TIMELINE_QUERY).should('have.text', getTimeline().query);
|
||||
closeTimeline();
|
||||
});
|
||||
|
@ -75,8 +72,7 @@ describe('Timelines', (): void => {
|
|||
deleteTimelines();
|
||||
});
|
||||
|
||||
// FLAKY: https://github.com/elastic/kibana/issues/169866
|
||||
describe.skip('Toggle create timeline from plus icon', () => {
|
||||
describe('Toggle create timeline from "New" btn', () => {
|
||||
context('Privileges: CRUD', { tags: '@ess' }, () => {
|
||||
beforeEach(() => {
|
||||
login();
|
||||
|
@ -84,6 +80,7 @@ describe('Timelines', (): void => {
|
|||
});
|
||||
|
||||
it('toggle create timeline ', () => {
|
||||
openTimelineUsingToggle();
|
||||
createNewTimeline();
|
||||
addNameAndDescriptionToTimeline(getTimeline());
|
||||
cy.get(TIMELINE_PANEL).should('be.visible');
|
||||
|
@ -97,12 +94,13 @@ describe('Timelines', (): void => {
|
|||
});
|
||||
|
||||
it('should not be able to create/update timeline ', () => {
|
||||
openTimelineUsingToggle();
|
||||
createNewTimeline();
|
||||
cy.get(TIMELINE_PANEL).should('be.visible');
|
||||
cy.get(TIMELINE_SAVE_MODAL_OPEN_BUTTON).should('be.disabled');
|
||||
cy.get(TIMELINE_SAVE_MODAL_OPEN_BUTTON).first().realHover();
|
||||
cy.get(SAVE_TIMELINE_BTN_TOOLTIP).should('be.visible');
|
||||
cy.get(SAVE_TIMELINE_BTN_TOOLTIP).should(
|
||||
cy.get(SAVE_TIMELINE_ACTION_BTN).should('be.disabled');
|
||||
cy.get(SAVE_TIMELINE_ACTION_BTN).first().realHover();
|
||||
cy.get(SAVE_TIMELINE_TOOLTIP).should('be.visible');
|
||||
cy.get(SAVE_TIMELINE_TOOLTIP).should(
|
||||
'have.text',
|
||||
'You can use Timeline to investigate events, but you do not have the required permissions to save timelines for future use. If you need to save timelines, contact your Kibana administrator.'
|
||||
);
|
||||
|
@ -153,6 +151,7 @@ describe('Timelines', (): void => {
|
|||
before(() => {
|
||||
login();
|
||||
visitWithTimeRange(OVERVIEW_URL);
|
||||
openTimelineUsingToggle();
|
||||
createNewTimeline();
|
||||
});
|
||||
|
||||
|
|
|
@ -6,20 +6,15 @@
|
|||
*/
|
||||
|
||||
import { TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON } from '../../../screens/security_main';
|
||||
import { CREATE_NEW_TIMELINE, TIMELINE_FLYOUT_HEADER } from '../../../screens/timeline';
|
||||
import { TIMELINE_FLYOUT_HEADER } from '../../../screens/timeline';
|
||||
|
||||
import { waitForAllHostsToBeLoaded } from '../../../tasks/hosts/all_hosts';
|
||||
import { login } from '../../../tasks/login';
|
||||
import { visitWithTimeRange } from '../../../tasks/navigation';
|
||||
import {
|
||||
closeTimelineUsingCloseButton,
|
||||
closeTimelineUsingToggle,
|
||||
openTimelineUsingToggle,
|
||||
} from '../../../tasks/security_main';
|
||||
import {
|
||||
closeCreateTimelineOptionsPopover,
|
||||
openCreateTimelineOptionsPopover,
|
||||
} from '../../../tasks/timeline';
|
||||
|
||||
import { hostsUrl } from '../../../urls/navigation';
|
||||
|
||||
|
@ -33,7 +28,7 @@ describe('timeline flyout button', () => {
|
|||
it('toggles open the timeline', { tags: ['@ess', '@serverless'] }, () => {
|
||||
openTimelineUsingToggle();
|
||||
cy.get(TIMELINE_FLYOUT_HEADER).should('have.css', 'visibility', 'visible');
|
||||
closeTimelineUsingToggle();
|
||||
closeTimelineUsingCloseButton();
|
||||
});
|
||||
|
||||
it(
|
||||
|
@ -41,7 +36,7 @@ describe('timeline flyout button', () => {
|
|||
{ tags: ['@ess', '@serverless'] },
|
||||
() => {
|
||||
openTimelineUsingToggle();
|
||||
closeTimelineUsingToggle();
|
||||
closeTimelineUsingCloseButton();
|
||||
|
||||
cy.get(TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON).should('have.focus');
|
||||
}
|
||||
|
@ -69,18 +64,6 @@ describe('timeline flyout button', () => {
|
|||
}
|
||||
);
|
||||
|
||||
it(
|
||||
'the `(+)` button popover menu owns focus when open',
|
||||
{ tags: ['@ess', '@serverless'] },
|
||||
() => {
|
||||
openCreateTimelineOptionsPopover();
|
||||
cy.get(CREATE_NEW_TIMELINE).focus();
|
||||
cy.get(CREATE_NEW_TIMELINE).should('have.focus');
|
||||
closeCreateTimelineOptionsPopover();
|
||||
cy.get(CREATE_NEW_TIMELINE).should('not.exist');
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
'should render the global search dropdown when the input is focused',
|
||||
{ tags: ['@ess'] },
|
||||
|
|
|
@ -7,11 +7,7 @@
|
|||
|
||||
import { getTimeline } from '../../../objects/timeline';
|
||||
|
||||
import {
|
||||
TIMELINE_DESCRIPTION,
|
||||
TIMELINE_TITLE,
|
||||
OPEN_TIMELINE_MODAL,
|
||||
} from '../../../screens/timeline';
|
||||
import { TIMELINE_TITLE, OPEN_TIMELINE_MODAL } from '../../../screens/timeline';
|
||||
import {
|
||||
TIMELINES_DESCRIPTION,
|
||||
TIMELINES_PINNED_EVENT_COUNT,
|
||||
|
@ -69,7 +65,6 @@ describe('Open timeline', { tags: ['@serverless', '@ess'] }, () => {
|
|||
cy.get(TIMELINES_NOTES_COUNT).last().should('have.text', '1');
|
||||
cy.get(TIMELINES_FAVORITE).last().should('exist');
|
||||
cy.get(TIMELINE_TITLE).should('have.text', getTimeline().title);
|
||||
cy.get(TIMELINE_DESCRIPTION).should('have.text', getTimeline().description);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue