[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|
|--|--|

|![image](b989e2e5-d124-400c-b12e-24c306d38561)|

### 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|
|---|---|

|![image](c2f0ea5b-bf7b-48e2-b14f-43b9afee16bc)|

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|
|---|---|

|![image](44ac7f00-3897-4c64-86f9-161376290b2e)|


- 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|
|--|--|

|![image](fee4e846-f0d3-45bf-9139-d3b21d93c567)|


### Timeline Header Panel

Below timeline header panel has been completely removed.


![image](c7eb27a2-0314-49e6-8e6d-8db11badd4a8)
 

### 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|
|---|---|

|![image](173debdb-cdae-4547-a5f2-913c1b4561aa)|

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

![Screenshot 2023-11-21 at 11 58
31](9e62491e-a500-4a94-9421-cb2fdcb7eb7c)

--------------
#### 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.


![image](d2df322b-23dc-4f1b-9167-ece32ca70947)


### 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:
Jatin Kathuria 2023-11-27 21:32:32 +01:00 committed by GitHub
parent f7fa8469bd
commit 72d2457ee2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
108 changed files with 3230 additions and 2143 deletions

View file

@ -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

View file

@ -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;

View file

@ -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);
}
};

View file

@ -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>
</>
);
};

View file

@ -37,7 +37,7 @@ exports[`HeaderSection it renders 1`] = `
>
<EuiFlexItem>
<EuiTitle
size="m"
size="l"
>
<h4
data-test-subj="header-section-title"

View file

@ -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}

View file

@ -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}

View file

@ -167,7 +167,6 @@ export const QueryBar = memo<QueryBarComponentProps>(
savedQuery={savedQuery}
displayStyle={displayStyle}
isDisabled={isDisabled}
hideTextBasedRunQueryLabel
/>
);
}

View file

@ -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();
});
});

View file

@ -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;

View file

@ -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."
);
});
});

View file

@ -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}

View file

@ -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-*'],
})
);
});
});

View file

@ -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-*'],
})
);
});
});

View file

@ -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} />
</>
)}

View file

@ -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();
});
});
});

View file

@ -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(

View file

@ -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>
);
};

View file

@ -233,7 +233,7 @@ export const convertToBuildEsQuery = ({
export const combineQueries = ({
config,
dataProviders,
dataProviders = [],
indexPattern,
browserFields,
filters = [],

View file

@ -372,6 +372,7 @@ export const mockGlobalState: State = {
itemsPerPageOptions: [10, 25, 50, 100],
savedSearchId: null,
isDiscoverSavedSearchLoaded: false,
isDataProviderVisible: true,
},
},
insertTimeline: null,

View file

@ -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,

View file

@ -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'],
};

View file

@ -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>
);
};

View file

@ -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>
);

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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();
});
});
});

View file

@ -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);

View file

@ -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';

View file

@ -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';

View file

@ -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',

View file

@ -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}

View file

@ -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(),
}));

View file

@ -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 {

View file

@ -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',
{

View file

@ -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}
/>

View file

@ -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]
);

View file

@ -10,6 +10,7 @@ import { FlyoutHeaderPanel } from '../header';
interface FlyoutBottomBarProps {
showTimelineHeaderPanel: boolean;
timelineId: string;
}

View file

@ -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/);
});
});
});

View file

@ -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>
);
};

View file

@ -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();
});
});
});
});

View file

@ -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);

View file

@ -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';

View file

@ -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/);
});
});

View file

@ -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';

View file

@ -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',
});

View file

@ -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};
}
}
`;
};

View file

@ -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';

View file

@ -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>

View file

@ -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 && (

View file

@ -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>
</>
);
};

View file

@ -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>
</>
);
});

View file

@ -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;
}
`;

View file

@ -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',
}
);

View file

@ -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

View file

@ -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

View file

@ -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>
`;

View file

@ -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);

View file

@ -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';

View file

@ -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

View file

@ -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();
});
});
});

View file

@ -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';

View file

@ -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>
);
};

View file

@ -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';

View file

@ -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',
});

View file

@ -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}

View file

@ -44,7 +44,7 @@ describe('NewTimeline', () => {
const mockGetButton = jest.fn().mockReturnValue('<></>');
const props: NewTimelineProps = {
closeGearMenu: jest.fn(),
onClick: jest.fn(),
timelineId: 'mockTimelineId',
title: 'mockTitle',
};

View file

@ -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 });

View file

@ -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>
);
});

View file

@ -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 });

View file

@ -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(

View file

@ -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>
);
}
);

View file

@ -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>
);

View file

@ -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);

View file

@ -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) => ({

View file

@ -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.',
}
);

View file

@ -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`}

View file

@ -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;
};

View file

@ -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 />
</>
);
}

View file

@ -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',
}
);

View file

@ -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

View file

@ -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');

View file

@ -80,6 +80,7 @@ export const timelineDefaults: SubsetTimelineModel &
filters: [],
savedSearchId: null,
isDiscoverSavedSearchLoaded: false,
isDataProviderVisible: false,
};
export const getTimelineManageDefaults = (id: string) => ({

View file

@ -176,6 +176,7 @@ describe('Epic Timeline', () => {
id: '11169110-fc22-11e9-8ca9-072f15ce2685',
savedQueryId: 'my endgame timeline query',
savedSearchId: null,
isDataProviderVisible: true,
};
expect(

View file

@ -138,6 +138,7 @@ const basicTimeline: TimelineModel = {
title: '',
version: null,
savedSearchId: null,
isDataProviderVisible: true,
};
const timelineByIdMock: TimelineById = {
foo: { ...basicTimeline },

View file

@ -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'
>
>;

View file

@ -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: {

View file

@ -54,3 +54,6 @@ export const getKqlFilterKuerySelector = () =>
? timeline.kqlQuery.filterQuery.kuery
: null
);
export const dataProviderVisibilitySelector = () =>
createSelector(selectTimeline, (timeline) => timeline.isDataProviderVisible);

View file

@ -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",

View file

@ -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",

View file

@ -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 でフィルターされます",

View file

@ -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 进行筛选",

View file

@ -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);

View file

@ -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"}}]}}'
);

View file

@ -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);
});
});

View file

@ -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();
});

View file

@ -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'] },

View file

@ -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