mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
## Summary (#29938)
## Summary Adds notes to events and closes Timeline issues * closes [Add Notes to events](https://github.com/elastic/ingest-dev/issues/241)  * closes [Drag and drop values from columns into data providers](https://github.com/elastic/ingest-dev/issues/153)  * closes [Increase the Information Density in Timeline Rows](https://github.com/elastic/ingest-dev/issues/238) <img width="1680" alt="final-layout-15-inch-mbp" src="https://user-images.githubusercontent.com/4459398/52267380-1b80c300-28f6-11e9-8535-f8fa32dc74a9.png"> * closes [Make Curated Content in the Suricata Row Renderer Content Draggable to the Query](https://github.com/elastic/ingest-dev/issues/240)  * closes [Values dragged from the field browser do not appear as "cards" when dragged](https://github.com/elastic/ingest-dev/issues/154) * closes [Resize handle style](https://github.com/elastic/ingest-dev/issues/170) <img width="1022" alt="resize-handle" src="https://user-images.githubusercontent.com/4459398/52268818-a8794b80-28f9-11e9-8293-c071fa47c429.png"> * closes [Star icon uses different icons for Favorite / Not a favorite states(https://github.com/elastic/ingest-dev/issues/164)  * closes [Notes in the Add note modal can overflow the modal](https://github.com/elastic/ingest-dev/issues/159) * closes [Replace "placeholder" JSON in expandable row / accordion](https://github.com/elastic/ingest-dev/issues/171) (by removing the JSON preview) - the inline JSON preview was removed * closes [Add function call back to the pin column](https://github.com/elastic/ingest-dev/issues/97) ## Other changes * timeline footer is now a single-line, and responsive  * updated the search / filter select to indicate whether it is an AND or OR operation  * tweaked styling of AND / OR badges, and updated the "Drop anything highlighted" message to use an OR badge * added placeholders for row-selection and the field-browser * replaced some custom flexbox CSS with EUI * show humanized @timestamp in tooltip (in column and table views): <img width="508" alt="timestamp-tooltip" src="https://user-images.githubusercontent.com/4459398/52269219-b7acc900-28fa-11e9-87ef-4ea32b3638eb.png"> * removed hard-coded protocol:TCP in Suricata Row Renderer * removed the always-visible border from suricata row renderer * headers is now a prop from mapStateToProps, in preparation for the field browser * responsive columns widths (in preparation for resizable columns)
This commit is contained in:
parent
7074b57b70
commit
f05bf082d9
76 changed files with 3133 additions and 958 deletions
|
@ -53,6 +53,7 @@
|
|||
"x-pack/plugins/secops/public/components/flyout/header/index.tsx",
|
||||
"x-pack/plugins/secops/public/components/flyout/index.tsx",
|
||||
"x-pack/plugins/secops/public/components/flyout/resize_handle.tsx",
|
||||
"x-pack/plugins/secops/public/components/notes/helpers.tsx",
|
||||
"x-pack/plugins/secops/public/components/timeline/properties/helpers.tsx",
|
||||
"x-pack/plugins/secops/public/components/timeline/properties/index.tsx",
|
||||
"x-pack/plugins/secops/public/components/timeline/search_or_filter/search_or_filter.tsx",
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EuiBadge } from '@elastic/eui';
|
||||
import * as React from 'react';
|
||||
import { pure } from 'recompose';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import * as i18n from './translations';
|
||||
|
||||
const RoundedBadge = styled(EuiBadge)`
|
||||
align-items: center;
|
||||
border-radius: 100%;
|
||||
display: inline-flex;
|
||||
font-size: 9px;
|
||||
height: 19px;
|
||||
justify-content: center;
|
||||
margin: 0 5px 0 5px;
|
||||
padding: 7px 6px 4px 6px;
|
||||
user-select: none;
|
||||
width: 19px;
|
||||
|
||||
.euiBadge__content {
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
.euiBadge__text {
|
||||
text-overflow: clip;
|
||||
}
|
||||
`;
|
||||
|
||||
export type AndOr = 'and' | 'or';
|
||||
|
||||
/** Displays AND / OR in a round badge */
|
||||
export const AndOrBadge = pure<{ type: AndOr }>(({ type }) => (
|
||||
<RoundedBadge data-test-subj="and-or-badge" color="hollow">
|
||||
{type === 'and' ? i18n.AND : i18n.OR}
|
||||
</RoundedBadge>
|
||||
));
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const AND = i18n.translate('xpack.secops.andOrBadge.and', {
|
||||
defaultMessage: 'AND',
|
||||
});
|
||||
|
||||
export const OR = i18n.translate('xpack.secops.andOrBadge.or', {
|
||||
defaultMessage: 'OR',
|
||||
});
|
|
@ -53,7 +53,7 @@ const ReactDndDropTarget = styled.div<{ isDraggingOver: boolean }>`
|
|||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
`
|
||||
: ''}
|
||||
> div.timeline-drop-area {
|
||||
|
|
|
@ -65,13 +65,12 @@ export const columns = [
|
|||
hoverContent={
|
||||
<HoverActionsContainer data-test-subj="hover-actions-container">
|
||||
<EuiToolTip content={i18n.COPY_TO_CLIPBOARD}>
|
||||
<WithCopyToClipboard text={field} />
|
||||
<WithCopyToClipboard text={field} titleSummary={i18n.FIELD} />
|
||||
</EuiToolTip>
|
||||
</HoverActionsContainer>
|
||||
}
|
||||
>
|
||||
<span>{field}</span>
|
||||
</WithHoverActions>
|
||||
render={() => <span>{field}</span>}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
@ -84,13 +83,12 @@ export const columns = [
|
|||
hoverContent={
|
||||
<HoverActionsContainer data-test-subj="hover-actions-container">
|
||||
<EuiToolTip content={i18n.COPY_TO_CLIPBOARD}>
|
||||
<WithCopyToClipboard text={item.valueAsString} />
|
||||
<WithCopyToClipboard text={item.valueAsString} titleSummary={i18n.VALUE} />
|
||||
</EuiToolTip>
|
||||
</HoverActionsContainer>
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</WithHoverActions>
|
||||
render={() => value}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json';
|
||||
import { mount } from 'enzyme';
|
||||
import { noop } from 'lodash/fp';
|
||||
import * as React from 'react';
|
||||
import { DragDropContext } from 'react-beautiful-dnd';
|
||||
import { Provider as ReduxStoreProvider } from 'react-redux';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
|
||||
import { mockEcsData } from '../../mock/mock_ecs';
|
||||
import { createStore } from '../../store';
|
||||
import { EventDetails } from './event_details';
|
||||
|
||||
describe('EventDetails', () => {
|
||||
let store = createStore();
|
||||
|
||||
beforeEach(() => {
|
||||
store = createStore();
|
||||
});
|
||||
|
||||
describe('tabs', () => {
|
||||
['Table', 'JSON View'].forEach(tab => {
|
||||
test(`it renders the ${tab} tab`, () => {
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<EventDetails data={mockEcsData[0]} view="table-view" onViewSelected={noop} />
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-test-subj="eventDetails"]')
|
||||
.find('[role="tablist"]')
|
||||
.containsMatchingElement(<span>{tab}</span>)
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test('the Table tab is selected by default', () => {
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<EventDetails data={mockEcsData[0]} view="table-view" onViewSelected={noop} />
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-test-subj="eventDetails"]')
|
||||
.find('.euiTab-isSelected')
|
||||
.first()
|
||||
.text()
|
||||
).toEqual('Table');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,150 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json';
|
||||
import { noop } from 'lodash/fp';
|
||||
import * as React from 'react';
|
||||
import { DragDropContext } from 'react-beautiful-dnd';
|
||||
import { Provider as ReduxStoreProvider } from 'react-redux';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import { mountWithIntl } from 'test_utils/enzyme_helpers';
|
||||
|
||||
import { mockEcsData } from '../../mock/mock_ecs';
|
||||
import { createStore } from '../../store';
|
||||
import { EventFieldsBrowser } from './event_fields_browser';
|
||||
|
||||
describe('EventFieldsBrowser', () => {
|
||||
describe('column headers', () => {
|
||||
['Field', 'Value', 'Description'].forEach(header => {
|
||||
test(`it renders the ${header} column header`, () => {
|
||||
const store = createStore();
|
||||
const wrapper = mountWithIntl(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<EventFieldsBrowser data={mockEcsData[0]} />
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(wrapper.find('thead').containsMatchingElement(<span>{header}</span>)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('filter input', () => {
|
||||
test('it renders a filter input with the expected placeholder', () => {
|
||||
const store = createStore();
|
||||
const wrapper = mountWithIntl(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<EventFieldsBrowser data={mockEcsData[0]} />
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(wrapper.find('input[type="search"]').props().placeholder).toEqual(
|
||||
'Filter by Field, Value, or Description...'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('field type icon', () => {
|
||||
test('it renders the expected icon type for the data provided', () => {
|
||||
const store = createStore();
|
||||
const wrapper = mountWithIntl(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<EventFieldsBrowser data={mockEcsData[0]} />
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find('.euiTableRow')
|
||||
.find('.euiTableRowCell')
|
||||
.at(0)
|
||||
.find('svg')
|
||||
.exists()
|
||||
).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('field', () => {
|
||||
test('it renders the field name for the data provided', () => {
|
||||
const store = createStore();
|
||||
const wrapper = mountWithIntl(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<EventFieldsBrowser data={mockEcsData[0]} />
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find('.euiTableRow')
|
||||
.find('.euiTableRowCell')
|
||||
.at(1)
|
||||
.containsMatchingElement(<span>@timestamp</span>)
|
||||
).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('value', () => {
|
||||
test('it renders the expected value for the data provided', () => {
|
||||
const store = createStore();
|
||||
const wrapper = mountWithIntl(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<EventFieldsBrowser data={mockEcsData[0]} />
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find('.euiTableRow')
|
||||
.find('.euiTableRowCell')
|
||||
.at(2)
|
||||
.text()
|
||||
).toEqual('2018-11-05T19:03:25.937Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('description', () => {
|
||||
test('it renders the expected field description the data provided', () => {
|
||||
const store = createStore();
|
||||
const wrapper = mountWithIntl(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<EventFieldsBrowser data={mockEcsData[0]} />
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find('.euiTableRow')
|
||||
.find('.euiTableRowCell')
|
||||
.at(3)
|
||||
.text()
|
||||
).toContain('Date/time when the event originated.');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,122 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { getPopulatedMappedFields, virtualEcsSchema } from '../../../public/lib/ecs';
|
||||
import { mockEcsData } from '../../mock/mock_ecs';
|
||||
import { getExampleText, getIconFromType, getItems } from './helpers';
|
||||
|
||||
const aField = virtualEcsSchema.event.fields['event.category'];
|
||||
|
||||
describe('helpers', () => {
|
||||
describe('getExampleText', () => {
|
||||
test('it returns the expected example text when the field contains an example', () => {
|
||||
expect(getExampleText(aField)).toEqual('Example: user-management');
|
||||
});
|
||||
|
||||
test(`it returns an empty string when the field's example is an empty string`, () => {
|
||||
const fieldWithEmptyExample = {
|
||||
...aField,
|
||||
example: '',
|
||||
};
|
||||
|
||||
expect(getExampleText(fieldWithEmptyExample)).toEqual('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIconFromType', () => {
|
||||
[
|
||||
{
|
||||
type: 'keyword',
|
||||
expected: 'string',
|
||||
},
|
||||
{
|
||||
type: 'long',
|
||||
expected: 'number',
|
||||
},
|
||||
{
|
||||
type: 'date',
|
||||
expected: 'clock',
|
||||
},
|
||||
{
|
||||
type: 'ip',
|
||||
expected: 'globe',
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
expected: 'questionInCircle',
|
||||
},
|
||||
{
|
||||
type: 'float',
|
||||
expected: 'number',
|
||||
},
|
||||
{
|
||||
type: 'anything else',
|
||||
expected: 'questionInCircle',
|
||||
},
|
||||
].forEach(({ type, expected }) => {
|
||||
test(`it returns a ${expected} icon for type ${type}`, () =>
|
||||
expect(getIconFromType(type)).toEqual(expected));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getItems', () => {
|
||||
const data = mockEcsData[0];
|
||||
|
||||
test('it returns the expected number of populated fields', () => {
|
||||
expect(
|
||||
getItems({
|
||||
data,
|
||||
populatedFields: getPopulatedMappedFields({ data, schema: virtualEcsSchema }),
|
||||
}).length
|
||||
).toEqual(17);
|
||||
});
|
||||
|
||||
test('it includes the "event.category" field', () => {
|
||||
getItems({
|
||||
data,
|
||||
populatedFields: getPopulatedMappedFields({ data, schema: virtualEcsSchema }),
|
||||
}).some(x => x.field === 'event.category');
|
||||
});
|
||||
|
||||
test('it returns the expected description', () => {
|
||||
expect(
|
||||
getItems({
|
||||
data,
|
||||
populatedFields: getPopulatedMappedFields({ data, schema: virtualEcsSchema }),
|
||||
}).find(x => x.field === 'event.category')!.description
|
||||
).toEqual(
|
||||
'Event category.\nThis contains high-level information about the contents of the event. It is more generic than `event.action`, in the sense that typically a category contains multiple actions. Warning: In future versions of ECS, we plan to provide a list of acceptable values for this field, please use with caution. Example: user-management'
|
||||
);
|
||||
});
|
||||
|
||||
test('it returns the expected type', () => {
|
||||
expect(
|
||||
getItems({
|
||||
data,
|
||||
populatedFields: getPopulatedMappedFields({ data, schema: virtualEcsSchema }),
|
||||
}).find(x => x.field === 'event.category')!.type
|
||||
).toEqual('keyword');
|
||||
});
|
||||
|
||||
test('it returns the expected valueAsString', () => {
|
||||
expect(
|
||||
getItems({
|
||||
data,
|
||||
populatedFields: getPopulatedMappedFields({ data, schema: virtualEcsSchema }),
|
||||
}).find(x => x.field === 'event.category')!.valueAsString
|
||||
).toEqual('Access');
|
||||
});
|
||||
|
||||
test('it returns a draggable wrapper with the expected value.key', () => {
|
||||
expect(
|
||||
getItems({
|
||||
data,
|
||||
populatedFields: getPopulatedMappedFields({ data, schema: virtualEcsSchema }),
|
||||
}).find(x => x.field === 'event.category')!.value.key
|
||||
).toMatch(/^event-field-browser-value-for-event.category-\S+$/);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -4,14 +4,21 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EuiToolTip } from '@elastic/eui';
|
||||
import { FormattedRelative } from '@kbn/i18n/react';
|
||||
import { getOr } from 'lodash/fp';
|
||||
import moment from 'moment';
|
||||
import * as React from 'react';
|
||||
import uuid from 'uuid';
|
||||
import { pure } from 'recompose';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Ecs } from '../../graphql/types';
|
||||
import { EcsField, getMappedEcsValue, mappedEcsSchemaFieldNames } from '../../lib/ecs';
|
||||
import { escapeQueryValue } from '../../lib/keury';
|
||||
import { DraggableWrapper } from '../drag_and_drop/draggable_wrapper';
|
||||
import { DragEffects, DraggableWrapper } from '../drag_and_drop/draggable_wrapper';
|
||||
import { escapeDataProviderId } from '../drag_and_drop/helpers';
|
||||
import { Provider } from '../timeline/data_providers/provider';
|
||||
import * as i18n from './translations';
|
||||
|
||||
/**
|
||||
* Defines the behavior of the search input that appears above the table of data
|
||||
|
@ -19,7 +26,7 @@ import { DraggableWrapper } from '../drag_and_drop/draggable_wrapper';
|
|||
export const search = {
|
||||
box: {
|
||||
incremental: true,
|
||||
placeholder: 'Filter by Field, Value, or Description...',
|
||||
placeholder: i18n.PLACEHOLDER,
|
||||
schema: {
|
||||
field: {
|
||||
type: 'string',
|
||||
|
@ -75,46 +82,89 @@ interface GetItemsParams {
|
|||
populatedFields: EcsField[];
|
||||
}
|
||||
|
||||
const DatesContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
export const DateFieldWithTooltip = pure<{ dateString: string }>(({ dateString }) => (
|
||||
<EuiToolTip
|
||||
data-test-subj="timeline-event-timestamp-tool-tip"
|
||||
content={
|
||||
<DatesContainer>
|
||||
<FormattedRelative data-test-subj="last-updated-at-date" value={new Date(dateString)} />
|
||||
<div>
|
||||
{moment(dateString)
|
||||
.local()
|
||||
.format('llll')}
|
||||
</div>
|
||||
<div>{moment(dateString).format()}</div>
|
||||
</DatesContainer>
|
||||
}
|
||||
>
|
||||
<>{dateString}</>
|
||||
</EuiToolTip>
|
||||
));
|
||||
|
||||
/**
|
||||
* Given `data`, the runtime representation of an event,
|
||||
* and `populatedFields`, an `EcsField[]` containing all the fields that are
|
||||
* populated in `data`, it returns an `Item[]`, so the data can be shown in
|
||||
* the table
|
||||
*/
|
||||
export const getItems = ({ data, populatedFields }: GetItemsParams): Item[] => {
|
||||
return populatedFields.map((field, i) => ({
|
||||
description: `${field.description} ${getExampleText(field)}`,
|
||||
field: field.name,
|
||||
type: field.type,
|
||||
valueAsString: `${getMappedEcsValue({
|
||||
data,
|
||||
fieldName: field.name,
|
||||
})}`,
|
||||
value: (
|
||||
<DraggableWrapper
|
||||
key={`event-field-browser-value-for-${field.name}-${uuid.v4()}`}
|
||||
dataProvider={{
|
||||
enabled: true,
|
||||
id: `id-event-field-browser-value-for-${field.name.replace('.', '_')}-${uuid.v4()}`, // escape '.'s in the field names
|
||||
name: `${field.name}: ${getMappedEcsValue({
|
||||
export const getItems = ({ data, populatedFields }: GetItemsParams): Item[] =>
|
||||
populatedFields.map(field => {
|
||||
const itemDataProvider = {
|
||||
enabled: true,
|
||||
id: escapeDataProviderId(`id-event-field-browser-value-for-${field.name}-${data._id!}`),
|
||||
name: `${field.name}: ${getMappedEcsValue({
|
||||
data,
|
||||
fieldName: field.name,
|
||||
})}`,
|
||||
queryMatch: {
|
||||
field: getOr(field.name, field.name, mappedEcsSchemaFieldNames),
|
||||
value: escapeQueryValue(
|
||||
getMappedEcsValue({
|
||||
data,
|
||||
fieldName: field.name,
|
||||
})}`,
|
||||
queryMatch: {
|
||||
field: getOr(field.name, field.name, mappedEcsSchemaFieldNames),
|
||||
value: escapeQueryValue(
|
||||
getMappedEcsValue({
|
||||
data,
|
||||
fieldName: field.name,
|
||||
})
|
||||
),
|
||||
},
|
||||
excluded: false,
|
||||
kqlQuery: '',
|
||||
and: [],
|
||||
}}
|
||||
render={() => `${getMappedEcsValue({ data, fieldName: field.name })}`}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
};
|
||||
})
|
||||
),
|
||||
},
|
||||
excluded: false,
|
||||
kqlQuery: '',
|
||||
and: [],
|
||||
};
|
||||
|
||||
return {
|
||||
description: `${field.description} ${getExampleText(field)}`,
|
||||
field: field.name,
|
||||
type: field.type,
|
||||
valueAsString: `${getMappedEcsValue({
|
||||
data,
|
||||
fieldName: field.name,
|
||||
})}`,
|
||||
value: (
|
||||
<DraggableWrapper
|
||||
key={`event-field-browser-value-for-${field.name}-${data._id!}`}
|
||||
dataProvider={itemDataProvider}
|
||||
render={(dataProvider, _, snapshot) =>
|
||||
snapshot.isDragging ? (
|
||||
<DragEffects>
|
||||
<Provider dataProvider={dataProvider} />
|
||||
</DragEffects>
|
||||
) : (
|
||||
<>
|
||||
{field.name !== '@timestamp' ? (
|
||||
`${getMappedEcsValue({ data, fieldName: field.name })}`
|
||||
) : (
|
||||
<DateFieldWithTooltip
|
||||
dateString={getMappedEcsValue({ data, fieldName: field.name })!}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
|
|
@ -13,6 +13,7 @@ import { pure } from 'recompose';
|
|||
import styled from 'styled-components';
|
||||
|
||||
import { Ecs } from '../../graphql/types';
|
||||
import { omitTypenameAndEmpty } from '../timeline/body/helpers';
|
||||
|
||||
interface Props {
|
||||
data: Ecs;
|
||||
|
@ -30,7 +31,7 @@ export const JsonView = pure<Props>(({ data }) => (
|
|||
setOptions={{ fontSize: '12px' }}
|
||||
value={JSON.stringify(
|
||||
data,
|
||||
null,
|
||||
omitTypenameAndEmpty,
|
||||
2 // indent level
|
||||
)}
|
||||
width="100%"
|
||||
|
|
|
@ -26,6 +26,10 @@ export const DESCRIPTION = i18n.translate('xpack.secops.eventDetails.description
|
|||
defaultMessage: 'Description',
|
||||
});
|
||||
|
||||
export const PLACEHOLDER = i18n.translate('xpack.secops.eventDetails.filter.placeholder', {
|
||||
defaultMessage: 'Filter by Field, Value, or Description...',
|
||||
});
|
||||
|
||||
export const COPY_TO_CLIPBOARD = i18n.translate('xpack.secops.eventDetails.copyToClipboard', {
|
||||
defaultMessage: 'Copy to Clipboard',
|
||||
});
|
||||
|
|
|
@ -19,7 +19,7 @@ import { FlyoutButton } from './button';
|
|||
import { FlyoutPane } from './pane';
|
||||
|
||||
/** The height in pixels of the flyout header, exported for use in height calculations */
|
||||
export const flyoutHeaderHeight: number = 50;
|
||||
export const flyoutHeaderHeight: number = 48;
|
||||
|
||||
export const Badge = styled(EuiBadge)`
|
||||
position: absolute;
|
||||
|
|
|
@ -29,15 +29,17 @@ const EuiFlyoutContainer = styled.div<{ headerHeight: number; width: number }>`
|
|||
}
|
||||
.timeline-flyout-header {
|
||||
align-items: center;
|
||||
box-shadow: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: ${({ headerHeight }) => `${headerHeight}px`};
|
||||
max-height: ${({ headerHeight }) => `${headerHeight}px`};
|
||||
overflow: hidden;
|
||||
padding: 5px 0 0 10px;
|
||||
}
|
||||
.timeline-flyout-body {
|
||||
overflow-y: hidden;
|
||||
padding: 10px 24px 24px 24px;
|
||||
padding: 0 5px 0 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
|
@ -70,7 +72,7 @@ export const FlyoutPane = pure<FlyoutPaneProps>(
|
|||
hideCloseButton={true}
|
||||
>
|
||||
<ResizeHandle height={flyoutHeight} timelineId={timelineId} />
|
||||
<EuiFlyoutHeader hasBorder className="timeline-flyout-header">
|
||||
<EuiFlyoutHeader hasBorder={false} className="timeline-flyout-header">
|
||||
<FlyoutHeaderContainer>
|
||||
<WrappedCloseButton>
|
||||
<EuiToolTip content={i18n.CLOSE_TIMELINE}>
|
||||
|
|
|
@ -62,7 +62,7 @@ export class ResizeHandleComponent extends React.PureComponent<Props> {
|
|||
const delta = (e as MouseEvent).movementX;
|
||||
const bodyClientWidthPixels = document.body.clientWidth;
|
||||
|
||||
const minWidthPixels = 310; // do not allow the flyout to shrink below this width (pixels)
|
||||
const minWidthPixels = 415; // do not allow the flyout to shrink below this width (pixels)
|
||||
const maxWidthPercent = 95; // do not allow the flyout to grow past this percentage of the view
|
||||
|
||||
applyDeltaToWidth({ id, delta, bodyClientWidthPixels, minWidthPixels, maxWidthPercent });
|
||||
|
|
|
@ -7,7 +7,9 @@
|
|||
import { EuiAccordion, EuiAccordionProps } from '@elastic/eui';
|
||||
import * as React from 'react';
|
||||
|
||||
type Props = Pick<EuiAccordionProps, Exclude<keyof EuiAccordionProps, 'initialIsOpen'>>;
|
||||
type Props = Pick<EuiAccordionProps, Exclude<keyof EuiAccordionProps, 'initialIsOpen'>> & {
|
||||
forceExpand?: boolean;
|
||||
};
|
||||
|
||||
interface State {
|
||||
expanded: boolean;
|
||||
|
@ -50,6 +52,7 @@ export class LazyAccordion extends React.PureComponent<Props, State> {
|
|||
id,
|
||||
buttonContentClassName,
|
||||
buttonContent,
|
||||
forceExpand,
|
||||
extraAction,
|
||||
paddingSize,
|
||||
children,
|
||||
|
@ -57,7 +60,7 @@ export class LazyAccordion extends React.PureComponent<Props, State> {
|
|||
|
||||
return (
|
||||
<>
|
||||
{this.state.expanded ? (
|
||||
{forceExpand || this.state.expanded ? (
|
||||
<>
|
||||
<EuiAccordion
|
||||
buttonContent={buttonContent}
|
||||
|
|
|
@ -7,26 +7,37 @@
|
|||
import { EuiLoadingChart, EuiPanel, EuiText } from '@elastic/eui';
|
||||
import * as React from 'react';
|
||||
import { pure } from 'recompose';
|
||||
import styled from 'styled-components';
|
||||
import styled, { injectGlobal } from 'styled-components';
|
||||
|
||||
// SIDE EFFECT: the following `injectGlobal` overrides default styling in angular code that was not theme-friendly
|
||||
// tslint:disable-next-line:no-unused-expression
|
||||
injectGlobal`
|
||||
.euiPanel-loading-hide-border {
|
||||
border: none;
|
||||
}
|
||||
`;
|
||||
|
||||
interface LoadingProps {
|
||||
text: string;
|
||||
height: number | string;
|
||||
showBorder?: boolean;
|
||||
width: number | string;
|
||||
}
|
||||
|
||||
export const LoadingPanel = pure<LoadingProps>(({ height = 'auto', text, width }) => (
|
||||
<InfraLoadingStaticPanel style={{ height, width }}>
|
||||
<InfraLoadingStaticContentPanel>
|
||||
<EuiPanel>
|
||||
<EuiLoadingChart size="m" />
|
||||
<EuiText>
|
||||
<p>{text}</p>
|
||||
</EuiText>
|
||||
</EuiPanel>
|
||||
</InfraLoadingStaticContentPanel>
|
||||
</InfraLoadingStaticPanel>
|
||||
));
|
||||
export const LoadingPanel = pure<LoadingProps>(
|
||||
({ height = 'auto', showBorder = true, text, width }) => (
|
||||
<InfraLoadingStaticPanel style={{ height, width }}>
|
||||
<InfraLoadingStaticContentPanel>
|
||||
<EuiPanel className={showBorder ? '' : 'euiPanel-loading-hide-border'}>
|
||||
<EuiLoadingChart size="m" />
|
||||
<EuiText>
|
||||
<p>{text}</p>
|
||||
</EuiText>
|
||||
</EuiPanel>
|
||||
</InfraLoadingStaticContentPanel>
|
||||
</InfraLoadingStaticPanel>
|
||||
)
|
||||
);
|
||||
|
||||
export const InfraLoadingStaticPanel = styled.div`
|
||||
position: relative;
|
||||
|
|
|
@ -7,12 +7,15 @@
|
|||
import { EuiPanel, EuiToolTip } from '@elastic/eui';
|
||||
import { FormattedRelative } from '@kbn/i18n/react';
|
||||
import * as React from 'react';
|
||||
import { pure } from 'recompose';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { WithCopyToClipboard } from '../../lib/clipboard/with_copy_to_clipboard';
|
||||
import { WithHoverActions } from '../with_hover_actions';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const Column = pure<{ text: string }>(({ text }) => <span>{text}</span>);
|
||||
|
||||
const HoverActionsContainer = styled(EuiPanel)`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
|
@ -55,9 +58,8 @@ export const columns = [
|
|||
</EuiToolTip>
|
||||
</HoverActionsContainer>
|
||||
}
|
||||
>
|
||||
<span>{field}</span>
|
||||
</WithHoverActions>
|
||||
render={() => <Column text={field} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
@ -74,9 +76,8 @@ export const columns = [
|
|||
</EuiToolTip>
|
||||
</HoverActionsContainer>
|
||||
}
|
||||
>
|
||||
<span>{note}</span>
|
||||
</WithHoverActions>
|
||||
render={() => <Column text={note} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EuiButton, EuiTextArea, EuiTitle, EuiToolTip } from '@elastic/eui';
|
||||
import { EuiButton, EuiTextArea, EuiTitle } from '@elastic/eui';
|
||||
import moment from 'moment';
|
||||
import * as React from 'react';
|
||||
import { pure } from 'recompose';
|
||||
|
@ -15,7 +15,7 @@ import * as i18n from './translations';
|
|||
|
||||
/** Performs IO to update (or add a new) note */
|
||||
export type UpdateNote = (note: Note) => void;
|
||||
/** Performs IO to associate a note with an (opaque to the caller) thing */
|
||||
/** Performs IO to associate a note with something (e.g. a timeline, an event, etc). (The "something" is opaque to the caller) */
|
||||
export type AssociateNote = (noteId: string) => void;
|
||||
/** Performs IO to get a new note ID */
|
||||
export type GetNewNoteId = () => string;
|
||||
|
@ -23,6 +23,8 @@ export type GetNewNoteId = () => string;
|
|||
export type UpdateInternalNewNote = (newNote: string) => void;
|
||||
/** Closes the notes popover */
|
||||
export type OnClosePopover = () => void;
|
||||
/** Performs IO to associate a note with an event */
|
||||
export type AddNoteToEvent = ({ eventId, noteId }: { eventId: string; noteId: string }) => void;
|
||||
|
||||
/**
|
||||
* Defines the behavior of the search input that appears above the table of data
|
||||
|
@ -30,7 +32,7 @@ export type OnClosePopover = () => void;
|
|||
export const search = {
|
||||
box: {
|
||||
incremental: true,
|
||||
placeholder: 'Filter by User or Note...',
|
||||
placeholder: i18n.SEARCH_PLACEHOLDER,
|
||||
schema: {
|
||||
user: {
|
||||
type: 'string',
|
||||
|
@ -50,12 +52,18 @@ const AddNotesContainer = styled.div`
|
|||
user-select: none;
|
||||
`;
|
||||
|
||||
const TitleText = styled.h3`
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
/** Displays a count of the existing notes */
|
||||
export const NotesCount = pure<{
|
||||
notes: Note[];
|
||||
}>(({ notes }) => (
|
||||
<EuiTitle size="s">
|
||||
<h3>{notes.length} Notes</h3>
|
||||
<TitleText>
|
||||
{notes.length} Note{notes.length === 1 ? '' : 's'}
|
||||
</TitleText>
|
||||
</EuiTitle>
|
||||
));
|
||||
|
||||
|
@ -126,24 +134,22 @@ export const AddNote = pure<{
|
|||
}>(({ associateNote, getNewNoteId, newNote, updateNewNote, updateNote }) => (
|
||||
<AddNotesContainer>
|
||||
<NewNote note={newNote} updateNewNote={updateNewNote} />
|
||||
<EuiToolTip data-test-subj="add-tool-tip" content={i18n.ADD_A_NEW_NOTE}>
|
||||
<EuiButton
|
||||
data-test-subj="Add Note"
|
||||
isDisabled={newNote.trim().length < 1}
|
||||
fill={true}
|
||||
onClick={() =>
|
||||
updateAndAssociateNode({
|
||||
getNewNoteId,
|
||||
newNote,
|
||||
updateNote,
|
||||
associateNote,
|
||||
updateNewNote,
|
||||
})
|
||||
}
|
||||
>
|
||||
{i18n.ADD_NOTE}
|
||||
</EuiButton>
|
||||
</EuiToolTip>
|
||||
<EuiButton
|
||||
data-test-subj="add-note"
|
||||
isDisabled={newNote.trim().length < 1}
|
||||
fill={true}
|
||||
onClick={() =>
|
||||
updateAndAssociateNode({
|
||||
associateNote,
|
||||
getNewNoteId,
|
||||
newNote,
|
||||
updateNewNote,
|
||||
updateNote,
|
||||
})
|
||||
}
|
||||
>
|
||||
{i18n.ADD_NOTE}
|
||||
</EuiButton>
|
||||
</AddNotesContainer>
|
||||
));
|
||||
|
||||
|
|
|
@ -50,11 +50,11 @@ const NotesContainer = styled.div`
|
|||
const InMemoryTable = styled(EuiInMemoryTable)`
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
height: 500px;
|
||||
|
||||
max-height: 500px;
|
||||
height: 220px;
|
||||
`;
|
||||
|
||||
// max-height: 220px;
|
||||
|
||||
/** A view for entering and reviewing notes */
|
||||
export class Notes extends React.PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
|
|
|
@ -18,10 +18,6 @@ export const ADD_NOTE = i18n.translate('xpack.secops.notes.addNote', {
|
|||
defaultMessage: 'Add note',
|
||||
});
|
||||
|
||||
export const ADD_A_NEW_NOTE = i18n.translate('xpack.secops.notes.addANewNote', {
|
||||
defaultMessage: 'Add a new note',
|
||||
});
|
||||
|
||||
export const ADD_A_NOTE = i18n.translate('xpack.secops.notes.addANote', {
|
||||
defaultMessage: 'Add a Note',
|
||||
});
|
||||
|
@ -30,6 +26,10 @@ export const NOTE = i18n.translate('xpack.secops.notes.note', {
|
|||
defaultMessage: 'Note',
|
||||
});
|
||||
|
||||
export const SEARCH_PLACEHOLDER = i18n.translate('xpack.secops.notes.search.placeholder', {
|
||||
defaultMessage: 'Filter by User or Note...',
|
||||
});
|
||||
|
||||
export const COPY_TO_CLIPBOARD = i18n.translate('xpack.secops.notes.copyToClipboard', {
|
||||
defaultMessage: 'Copy to clipboard',
|
||||
});
|
||||
|
|
19
x-pack/plugins/secops/public/components/pin/index.test.tsx
Normal file
19
x-pack/plugins/secops/public/components/pin/index.test.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { getPinRotation } from './';
|
||||
|
||||
describe('pin', () => {
|
||||
describe('getPinRotation', () => {
|
||||
test('it returns a vertical pin when pinned is true', () => {
|
||||
expect(getPinRotation(true)).toEqual('rotate(0)');
|
||||
});
|
||||
|
||||
test('it returns a rotated (UNpinned) pin when pinned is false', () => {
|
||||
expect(getPinRotation(false)).toEqual('rotate(45)');
|
||||
});
|
||||
});
|
||||
});
|
40
x-pack/plugins/secops/public/components/pin/index.tsx
Normal file
40
x-pack/plugins/secops/public/components/pin/index.tsx
Normal 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;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EuiIcon } from '@elastic/eui';
|
||||
import { noop } from 'lodash/fp';
|
||||
import * as React from 'react';
|
||||
import { pure } from 'recompose';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export type PinRotation = 'rotate(0)' | 'rotate(45)';
|
||||
|
||||
export const getPinRotation = (pinned: boolean): PinRotation =>
|
||||
pinned ? 'rotate(0)' : 'rotate(45)';
|
||||
|
||||
const PinIcon = styled(EuiIcon)<{ transform: string }>`
|
||||
overflow: hidden;
|
||||
transform: ${({ transform }) => transform};
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
allowUnpinning: boolean;
|
||||
pinned: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const Pin = pure<Props>(({ allowUnpinning, pinned, onClick = noop }) => (
|
||||
<PinIcon
|
||||
cursor={allowUnpinning ? 'pointer' : 'not-allowed'}
|
||||
color={pinned ? 'primary' : 'subdued'}
|
||||
data-test-subj="pin"
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
size="l"
|
||||
transform={getPinRotation(pinned)}
|
||||
type="pin"
|
||||
/>
|
||||
));
|
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
|
||||
import * as React from 'react';
|
||||
import { pure } from 'recompose';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Note } from '../../../../lib/note';
|
||||
import { AssociateNote, UpdateNote } from '../../../notes/helpers';
|
||||
import { Pin } from '../../../pin';
|
||||
import { NotesButton } from '../../properties/helpers';
|
||||
import { ACTIONS_COLUMN_WIDTH, eventHasNotes, getPinTooltip } from '../helpers';
|
||||
import * as i18n from '../translations';
|
||||
|
||||
interface Props {
|
||||
associateNote: AssociateNote;
|
||||
expanded: boolean;
|
||||
eventId: string;
|
||||
eventIsPinned: boolean;
|
||||
notes: Note[];
|
||||
onEventToggled: () => void;
|
||||
onPinClicked: () => void;
|
||||
showNotes: boolean;
|
||||
toggleShowNotes: () => void;
|
||||
updateNote: UpdateNote;
|
||||
}
|
||||
|
||||
const ActionsContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-width: ${ACTIONS_COLUMN_WIDTH}px;
|
||||
overflow: hidden;
|
||||
width: ${ACTIONS_COLUMN_WIDTH}px;
|
||||
`;
|
||||
|
||||
const ActionsRows = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const ActionsRow = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
min-width: ${ACTIONS_COLUMN_WIDTH}px;
|
||||
padding: 2px 8px 0 0;
|
||||
width: ${ACTIONS_COLUMN_WIDTH}px;
|
||||
`;
|
||||
|
||||
const PinContainer = styled.div`
|
||||
margin-right: 7px;
|
||||
`;
|
||||
|
||||
const emptyNotes: Note[] = [];
|
||||
|
||||
export const Actions = pure<Props>(
|
||||
({
|
||||
associateNote,
|
||||
expanded,
|
||||
eventId,
|
||||
eventIsPinned,
|
||||
notes,
|
||||
onEventToggled,
|
||||
onPinClicked,
|
||||
showNotes,
|
||||
toggleShowNotes,
|
||||
updateNote,
|
||||
}) => (
|
||||
<ActionsContainer data-test-subj="timeline-actions-container">
|
||||
<ActionsRows data-test-subj="timeline-actions-rows">
|
||||
<ActionsRow data-test-subj="timeline-actions-row">
|
||||
<EuiButtonIcon
|
||||
aria-label={expanded ? i18n.COLLAPSE : i18n.EXPAND}
|
||||
color="text"
|
||||
iconType={expanded ? 'arrowDown' : 'arrowRight'}
|
||||
data-test-subj="timeline-action-expand"
|
||||
id={eventId}
|
||||
onClick={onEventToggled}
|
||||
/>
|
||||
<EuiToolTip
|
||||
data-test-subj="timeline-action-pin-tool-tip"
|
||||
content={getPinTooltip({
|
||||
isPinned: eventIsPinned,
|
||||
eventHasNotes: eventHasNotes(notes),
|
||||
})}
|
||||
>
|
||||
<PinContainer>
|
||||
<Pin
|
||||
allowUnpinning={!eventHasNotes(notes)}
|
||||
pinned={eventIsPinned}
|
||||
data-test-subj="timeline-action-pin"
|
||||
onClick={onPinClicked}
|
||||
/>
|
||||
</PinContainer>
|
||||
</EuiToolTip>
|
||||
<NotesButton
|
||||
animate={false}
|
||||
associateNote={associateNote}
|
||||
data-test-subj="timeline-action-notes-button"
|
||||
notes={notes || emptyNotes}
|
||||
showNotes={showNotes}
|
||||
size="s"
|
||||
toggleShowNotes={toggleShowNotes}
|
||||
toolTip={i18n.NOTES_TOOLTIP}
|
||||
updateNote={updateNote}
|
||||
/>
|
||||
</ActionsRow>
|
||||
</ActionsRows>
|
||||
</ActionsContainer>
|
||||
)
|
||||
);
|
|
@ -6,14 +6,18 @@
|
|||
|
||||
import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json';
|
||||
import { mount } from 'enzyme';
|
||||
import { noop } from 'lodash/fp';
|
||||
import { get } from 'lodash/fp';
|
||||
import * as React from 'react';
|
||||
import { DragDropContext } from 'react-beautiful-dnd';
|
||||
import { Provider as ReduxStoreProvider } from 'react-redux';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
|
||||
import moment = require('moment');
|
||||
import { Body } from '.';
|
||||
import { Direction } from '../../../graphql/types';
|
||||
import { mockEcsData } from '../../../mock';
|
||||
import { headers } from './column_headers/headers';
|
||||
import { createStore } from '../../../store';
|
||||
import { defaultHeaders } from './column_headers/headers';
|
||||
import { columnRenderers, rowRenderers } from './renderers';
|
||||
|
||||
const testBodyHeight = 700;
|
||||
|
@ -21,54 +25,138 @@ const testBodyHeight = 700;
|
|||
describe('ColumnHeaders', () => {
|
||||
describe('rendering', () => {
|
||||
test('it renders each column of data (NOTE: this test omits timestamp, which is a special case tested below)', () => {
|
||||
const headersSansTimestamp = headers.filter(h => h.id !== 'timestamp');
|
||||
const store = createStore();
|
||||
const headersSansTimestamp = defaultHeaders.filter(h => h.id !== 'timestamp');
|
||||
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<Body
|
||||
id={'timeline-test'}
|
||||
columnHeaders={headersSansTimestamp}
|
||||
columnRenderers={columnRenderers}
|
||||
data={mockEcsData}
|
||||
rowRenderers={rowRenderers}
|
||||
height={testBodyHeight}
|
||||
/>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<Body
|
||||
addNoteToEvent={noop}
|
||||
id={'timeline-test'}
|
||||
columnHeaders={headersSansTimestamp}
|
||||
columnRenderers={columnRenderers}
|
||||
data={mockEcsData}
|
||||
height={testBodyHeight}
|
||||
notes={{}}
|
||||
onColumnSorted={noop}
|
||||
onFilterChange={noop}
|
||||
onPinEvent={noop}
|
||||
onRangeSelected={noop}
|
||||
onUnPinEvent={noop}
|
||||
pinnedEventIds={{}}
|
||||
range={'1 Day'}
|
||||
rowRenderers={rowRenderers}
|
||||
sort={{
|
||||
columnId: 'timestamp',
|
||||
sortDirection: Direction.descending,
|
||||
}}
|
||||
updateNote={noop}
|
||||
/>
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
headersSansTimestamp.forEach(h => {
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-test-subj="dataDrivenColumns"]')
|
||||
.find('[data-test-subj="data-driven-columns"]')
|
||||
.first()
|
||||
.text()
|
||||
).toContain(get(h.id, mockEcsData[0]));
|
||||
});
|
||||
});
|
||||
|
||||
test('it renders a formatted timestamp', () => {
|
||||
const headersJustTimestamp = headers.filter(h => h.id === 'timestamp');
|
||||
test('it renders a non-formatted timestamp', () => {
|
||||
const store = createStore();
|
||||
const headersJustTimestamp = defaultHeaders.filter(h => h.id === 'timestamp');
|
||||
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<Body
|
||||
id={'timeline-test'}
|
||||
columnHeaders={headersJustTimestamp}
|
||||
columnRenderers={columnRenderers}
|
||||
data={mockEcsData}
|
||||
rowRenderers={rowRenderers}
|
||||
height={testBodyHeight}
|
||||
/>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<Body
|
||||
addNoteToEvent={noop}
|
||||
id={'timeline-test'}
|
||||
columnHeaders={headersJustTimestamp}
|
||||
columnRenderers={columnRenderers}
|
||||
data={mockEcsData}
|
||||
height={testBodyHeight}
|
||||
notes={{}}
|
||||
onColumnSorted={noop}
|
||||
onFilterChange={noop}
|
||||
onPinEvent={noop}
|
||||
onRangeSelected={noop}
|
||||
onUnPinEvent={noop}
|
||||
pinnedEventIds={{}}
|
||||
range={'1 Day'}
|
||||
rowRenderers={rowRenderers}
|
||||
sort={{
|
||||
columnId: 'timestamp',
|
||||
sortDirection: Direction.descending,
|
||||
}}
|
||||
updateNote={noop}
|
||||
/>
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
headersJustTimestamp.forEach(h => {
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-test-subj="dataDrivenColumns"]')
|
||||
.find('[data-test-subj="data-driven-columns"]')
|
||||
.first()
|
||||
.text()
|
||||
).toContain(moment(get(h.id, mockEcsData[0])).format());
|
||||
).toEqual(get(h.id, mockEcsData[0]));
|
||||
});
|
||||
});
|
||||
|
||||
test('it renders a tooltip for timestamp', () => {
|
||||
const store = createStore();
|
||||
const headersJustTimestamp = defaultHeaders.filter(h => h.id === 'timestamp');
|
||||
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<Body
|
||||
addNoteToEvent={noop}
|
||||
id={'timeline-test'}
|
||||
columnHeaders={headersJustTimestamp}
|
||||
columnRenderers={columnRenderers}
|
||||
data={mockEcsData}
|
||||
height={testBodyHeight}
|
||||
notes={{}}
|
||||
onColumnSorted={noop}
|
||||
onFilterChange={noop}
|
||||
onPinEvent={noop}
|
||||
onRangeSelected={noop}
|
||||
onUnPinEvent={noop}
|
||||
pinnedEventIds={{}}
|
||||
range={'1 Day'}
|
||||
rowRenderers={rowRenderers}
|
||||
sort={{
|
||||
columnId: 'timestamp',
|
||||
sortDirection: Direction.descending,
|
||||
}}
|
||||
updateNote={noop}
|
||||
/>
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
headersJustTimestamp.forEach(h => {
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-test-subj="data-driven-columns"]')
|
||||
.first()
|
||||
.find('[data-test-subj="timeline-event-timestamp-tool-tip"]')
|
||||
.exists()
|
||||
).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,13 +5,13 @@
|
|||
*/
|
||||
|
||||
import { mount } from 'enzyme';
|
||||
import { noop } from 'lodash/fp';
|
||||
import * as React from 'react';
|
||||
|
||||
import { ColumnHeaders } from '.';
|
||||
import { Direction } from '../../../../graphql/types';
|
||||
import { ACTIONS_COLUMN_WIDTH } from '../helpers';
|
||||
import { Sort } from '../sort';
|
||||
import { headers } from './headers';
|
||||
import { defaultHeaders } from './headers';
|
||||
|
||||
describe('ColumnHeaders', () => {
|
||||
describe('rendering', () => {
|
||||
|
@ -22,13 +22,18 @@ describe('ColumnHeaders', () => {
|
|||
|
||||
test('it renders the other (data-driven) column headers', () => {
|
||||
const wrapper = mount(
|
||||
<ColumnHeaders columnHeaders={headers} range="1 Day" sort={sort} onRangeSelected={noop} />
|
||||
<ColumnHeaders
|
||||
actionsColumnWidth={ACTIONS_COLUMN_WIDTH}
|
||||
columnHeaders={defaultHeaders}
|
||||
sort={sort}
|
||||
timelineId={'test'}
|
||||
/>
|
||||
);
|
||||
|
||||
headers.forEach(h => {
|
||||
defaultHeaders.forEach(h => {
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-test-subj="columnHeaders"]')
|
||||
.find('[data-test-subj="column-headers"]')
|
||||
.first()
|
||||
.text()
|
||||
).toContain(h.text);
|
||||
|
|
|
@ -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;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EuiText } from '@elastic/eui';
|
||||
import * as React from 'react';
|
||||
import { pure } from 'recompose';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Pin } from '../../../../pin';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const InputDisplay = styled.div`
|
||||
width: 5px;
|
||||
`;
|
||||
|
||||
const PinIconContainer = styled.div`
|
||||
margin-right: 5px;
|
||||
`;
|
||||
|
||||
const PinActionItem = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
export type EventsSelectAction =
|
||||
| 'select-all'
|
||||
| 'select-none'
|
||||
| 'select-pinned'
|
||||
| 'select-unpinned'
|
||||
| 'pin-selected'
|
||||
| 'unpin-selected';
|
||||
|
||||
export interface EventsSelectOption {
|
||||
value: EventsSelectAction;
|
||||
inputDisplay: JSX.Element | string;
|
||||
disabled?: boolean;
|
||||
dropdownDisplay: JSX.Element | string;
|
||||
}
|
||||
|
||||
export const DropdownDisplay = pure<{ text: string }>(({ text }) => (
|
||||
<EuiText size="s" color="subdued">
|
||||
{text}
|
||||
</EuiText>
|
||||
));
|
||||
|
||||
export const getEventsSelectOptions = (): EventsSelectOption[] => [
|
||||
{
|
||||
inputDisplay: <InputDisplay />,
|
||||
disabled: true,
|
||||
dropdownDisplay: <DropdownDisplay text={i18n.SELECT_ALL} />,
|
||||
value: 'select-all',
|
||||
},
|
||||
{
|
||||
inputDisplay: <InputDisplay />,
|
||||
disabled: true,
|
||||
dropdownDisplay: <DropdownDisplay text={i18n.SELECT_NONE} />,
|
||||
value: 'select-none',
|
||||
},
|
||||
{
|
||||
inputDisplay: <InputDisplay />,
|
||||
disabled: true,
|
||||
dropdownDisplay: <DropdownDisplay text={i18n.SELECT_PINNED} />,
|
||||
value: 'select-pinned',
|
||||
},
|
||||
{
|
||||
inputDisplay: <InputDisplay />,
|
||||
disabled: true,
|
||||
dropdownDisplay: <DropdownDisplay text={i18n.SELECT_UNPINNED} />,
|
||||
value: 'select-unpinned',
|
||||
},
|
||||
{
|
||||
inputDisplay: <InputDisplay />,
|
||||
disabled: true,
|
||||
dropdownDisplay: (
|
||||
<PinActionItem>
|
||||
<PinIconContainer>
|
||||
<Pin allowUnpinning={true} pinned={true} />
|
||||
</PinIconContainer>
|
||||
<DropdownDisplay text={i18n.PIN_SELECTED} />
|
||||
</PinActionItem>
|
||||
),
|
||||
value: 'pin-selected',
|
||||
},
|
||||
{
|
||||
inputDisplay: <InputDisplay />,
|
||||
disabled: true,
|
||||
dropdownDisplay: (
|
||||
<PinActionItem>
|
||||
<PinIconContainer>
|
||||
<Pin allowUnpinning={true} pinned={false} />
|
||||
</PinIconContainer>
|
||||
<DropdownDisplay text={i18n.UNPIN_SELECTED} />
|
||||
</PinActionItem>
|
||||
),
|
||||
value: 'unpin-selected',
|
||||
},
|
||||
];
|
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import {
|
||||
EuiCheckbox,
|
||||
// @ts-ignore
|
||||
EuiSuperSelect,
|
||||
} from '@elastic/eui';
|
||||
import { noop } from 'lodash/fp';
|
||||
import * as React from 'react';
|
||||
import { pure } from 'recompose';
|
||||
import styled, { injectGlobal } from 'styled-components';
|
||||
|
||||
import { getEventsSelectOptions } from './helpers';
|
||||
|
||||
export type CheckState = 'checked' | 'indeterminate' | 'unchecked';
|
||||
|
||||
// SIDE EFFECT: the following `injectGlobal` overrides
|
||||
// the style of the select items
|
||||
// tslint:disable-next-line:no-unused-expression
|
||||
injectGlobal`
|
||||
.eventsSelectItem {
|
||||
width: 100% !important;
|
||||
|
||||
.euiContextMenu__icon {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.eventsSelectDropdown {
|
||||
width: 60px;
|
||||
}
|
||||
`;
|
||||
|
||||
const CheckboxContainer = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const PositionedCheckbox = styled.div`
|
||||
left: 7px;
|
||||
position: absolute;
|
||||
top: -28px;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
checkState: CheckState;
|
||||
timelineId: string;
|
||||
}
|
||||
|
||||
export const EventsSelect = pure<Props>(({ checkState, timelineId }) => {
|
||||
return (
|
||||
<div data-test-subj="events-select">
|
||||
<EuiSuperSelect
|
||||
className="eventsSelectDropdown"
|
||||
data-test-subj="events-select-dropdown"
|
||||
itemClassName="eventsSelectItem"
|
||||
onChange={noop}
|
||||
options={getEventsSelectOptions()}
|
||||
valueOfSelected={''}
|
||||
/>
|
||||
<CheckboxContainer data-test-subj="timeline-events-select-checkbox-container">
|
||||
<PositionedCheckbox data-test-subj="timeline-events-select-positioned-checkbox">
|
||||
<EuiCheckbox
|
||||
checked={checkState === 'checked'}
|
||||
data-test-subj="events-select-checkbox"
|
||||
disabled
|
||||
id={`timeline-${timelineId}-events-select`}
|
||||
indeterminate={checkState === 'indeterminate'}
|
||||
onChange={noop}
|
||||
/>
|
||||
</PositionedCheckbox>
|
||||
</CheckboxContainer>
|
||||
</div>
|
||||
);
|
||||
});
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const PIN_SELECTED = i18n.translate(
|
||||
'xpack.secops.timeline.eventsSelect.actions.pinSelected',
|
||||
{
|
||||
defaultMessage: 'Pin selected',
|
||||
}
|
||||
);
|
||||
|
||||
export const SELECT_ALL = i18n.translate('xpack.secops.timeline.eventsSelect.actions.selectAll', {
|
||||
defaultMessage: 'All',
|
||||
});
|
||||
|
||||
export const SELECT_NONE = i18n.translate('xpack.secops.timeline.eventsSelect.actions.selectNone', {
|
||||
defaultMessage: 'None',
|
||||
});
|
||||
|
||||
export const SELECT_PINNED = i18n.translate(
|
||||
'xpack.secops.timeline.eventsSelect.actions.selectPinned',
|
||||
{
|
||||
defaultMessage: 'Pinned',
|
||||
}
|
||||
);
|
||||
|
||||
export const SELECT_UNPINNED = i18n.translate(
|
||||
'xpack.secops.timeline.eventsSelect.actions.selectUnpinned',
|
||||
{
|
||||
defaultMessage: 'Unpinned',
|
||||
}
|
||||
);
|
||||
|
||||
export const UNPIN_SELECTED = i18n.translate(
|
||||
'xpack.secops.timeline.eventsSelect.actions.unpinSelected',
|
||||
{
|
||||
defaultMessage: 'Unpin selected',
|
||||
}
|
||||
);
|
|
@ -64,14 +64,14 @@ describe('Header', () => {
|
|||
});
|
||||
|
||||
describe('minWidth', () => {
|
||||
test('it applies the value of the minwidth prop to the headerContainer', () => {
|
||||
test('it applies the value of the minWidth prop to the headerContainer', () => {
|
||||
const wrapper = mount(<Header sort={sort} header={columnHeader} />);
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-test-subj="headerContainer"]')
|
||||
.first()
|
||||
.props()
|
||||
).toHaveProperty('minwidth', `${columnHeader.minWidth}px`);
|
||||
).toHaveProperty('minWidth', `${columnHeader.minWidth}px`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -65,11 +65,13 @@ interface Props {
|
|||
sort: Sort;
|
||||
}
|
||||
|
||||
const HeaderContainer = styled.div<{ minwidth: string }>`
|
||||
const HeaderContainer = styled.div<{ minWidth: string }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 8px;
|
||||
min-width: ${props => props.minwidth};
|
||||
max-width: 100%;
|
||||
min-width: ${props => props.minWidth};
|
||||
padding: 0 5px 0 5px;
|
||||
`;
|
||||
|
||||
const HeaderDiv = styled.div`
|
||||
|
@ -100,7 +102,7 @@ export const Header = pure<Props>(
|
|||
<HeaderContainer
|
||||
data-test-subj="headerContainer"
|
||||
key={header.id}
|
||||
minwidth={`${header.minWidth}px`}
|
||||
minWidth={`${header.minWidth}px`}
|
||||
>
|
||||
<HeaderDiv data-test-subj="header" onClick={onClick}>
|
||||
<Text data-test-subj="headerText">{header.text}</Text>
|
||||
|
|
|
@ -8,14 +8,16 @@ import { ColumnHeader } from './column_header';
|
|||
import * as i18n from './translations';
|
||||
|
||||
/** The default minimum width of a column */
|
||||
export const DEFAULT_COLUMN_MIN_WIDTH = 100;
|
||||
export const DEFAULT_COLUMN_MIN_WIDTH = 115;
|
||||
|
||||
export const TIMESTAMP_COLUMN_MIN_WIDTH = 185;
|
||||
|
||||
/** The default column headers */
|
||||
export const headers: ColumnHeader[] = [
|
||||
export const defaultHeaders: ColumnHeader[] = [
|
||||
{
|
||||
columnHeaderType: 'not-filtered',
|
||||
id: 'timestamp',
|
||||
minWidth: DEFAULT_COLUMN_MIN_WIDTH,
|
||||
minWidth: TIMESTAMP_COLUMN_MIN_WIDTH,
|
||||
text: i18n.TIME,
|
||||
},
|
||||
{
|
||||
|
@ -60,10 +62,4 @@ export const headers: ColumnHeader[] = [
|
|||
minWidth: DEFAULT_COLUMN_MIN_WIDTH,
|
||||
text: i18n.USER,
|
||||
},
|
||||
{
|
||||
columnHeaderType: 'not-filtered',
|
||||
id: 'event.id',
|
||||
minWidth: DEFAULT_COLUMN_MIN_WIDTH,
|
||||
text: i18n.EVENT,
|
||||
},
|
||||
];
|
||||
|
|
|
@ -4,64 +4,77 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui';
|
||||
import { noop } from 'lodash/fp';
|
||||
import * as React from 'react';
|
||||
import { pure } from 'recompose';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { OnColumnSorted, OnFilterChange, OnRangeSelected } from '../../events';
|
||||
import { OnColumnSorted, OnFilterChange } from '../../events';
|
||||
import { Sort } from '../sort';
|
||||
import { ColumnHeader } from './column_header';
|
||||
import { EventsSelect } from './events_select';
|
||||
import { Header } from './header';
|
||||
import { RangePicker } from './range_picker';
|
||||
|
||||
const SettingsContainer = styled.div`
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
`;
|
||||
|
||||
const EventsSelectAndSettingsContainer = styled.div<{ actionsColumnWidth: number }>`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-right: 5px;
|
||||
padding-right: 5px;
|
||||
width: ${({ actionsColumnWidth }) => actionsColumnWidth}px;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
actionsColumnWidth: number;
|
||||
columnHeaders: ColumnHeader[];
|
||||
onColumnSorted?: OnColumnSorted;
|
||||
onFilterChange?: OnFilterChange;
|
||||
onRangeSelected: OnRangeSelected;
|
||||
range: string;
|
||||
sort: Sort;
|
||||
timelineId: string;
|
||||
}
|
||||
|
||||
const ColumnHeadersSpan = styled.span`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
/* stylelint-disable block-no-empty */
|
||||
const ColumnHeaderContainer = styled.div``;
|
||||
|
||||
const Flex = styled.div`
|
||||
display: flex;
|
||||
margin-left: 5px;
|
||||
user-select: none;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
/** Renders the timeline header columns */
|
||||
export const ColumnHeaders = pure<Props>(
|
||||
({
|
||||
actionsColumnWidth,
|
||||
columnHeaders,
|
||||
onColumnSorted = noop,
|
||||
onFilterChange = noop,
|
||||
onRangeSelected,
|
||||
range,
|
||||
sort,
|
||||
timelineId,
|
||||
}) => (
|
||||
<ColumnHeadersSpan data-test-subj="columnHeaders">
|
||||
<RangePicker selected={range} onRangeSelected={onRangeSelected} />
|
||||
<Flex>
|
||||
{columnHeaders.map(header => (
|
||||
<ColumnHeaderContainer data-test-subj="columnHeaderContainer" key={header.id}>
|
||||
<Header
|
||||
header={header}
|
||||
onColumnSorted={onColumnSorted}
|
||||
onFilterChange={onFilterChange}
|
||||
sort={sort}
|
||||
/>
|
||||
</ColumnHeaderContainer>
|
||||
))}
|
||||
</Flex>
|
||||
</ColumnHeadersSpan>
|
||||
<EuiFlexGroup data-test-subj="column-headers" gutterSize="none">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EventsSelectAndSettingsContainer
|
||||
actionsColumnWidth={actionsColumnWidth}
|
||||
data-test-subj="events-select-and-settings-container"
|
||||
>
|
||||
<EventsSelect checkState="unchecked" timelineId={timelineId} />
|
||||
<SettingsContainer data-test-subj="settings-container">
|
||||
<EuiIcon data-test-subj="gear" type="gear" size="l" onClick={noop} />
|
||||
</SettingsContainer>
|
||||
</EventsSelectAndSettingsContainer>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={true}>
|
||||
<EuiFlexGroup gutterSize="none">
|
||||
{columnHeaders.map(header => (
|
||||
<EuiFlexItem key={header.id}>
|
||||
<Header
|
||||
header={header}
|
||||
onColumnSorted={onColumnSorted}
|
||||
onFilterChange={onFilterChange}
|
||||
sort={sort}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
)
|
||||
);
|
||||
|
|
|
@ -0,0 +1,247 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { Ecs } from '../../../graphql/types';
|
||||
import { eventHasNotes, eventIsPinned, getPinTooltip, stringifyEvent } from './helpers';
|
||||
|
||||
describe('helpers', () => {
|
||||
describe('stringifyEvent', () => {
|
||||
test('it omits __typename when it appears at arbitrary levels', () => {
|
||||
expect(
|
||||
JSON.parse(
|
||||
stringifyEvent({
|
||||
__typename: 'level 0',
|
||||
_id: '4',
|
||||
timestamp: '2018-11-08T19:03:25.937Z',
|
||||
host: {
|
||||
__typename: 'level 1',
|
||||
name: 'suricata',
|
||||
ip: ['192.168.0.1'],
|
||||
},
|
||||
event: {
|
||||
id: 4,
|
||||
category: 'Attempted Administrator Privilege Gain',
|
||||
type: 'Alert',
|
||||
module: 'suricata',
|
||||
severity: 1,
|
||||
},
|
||||
source: {
|
||||
ip: '192.168.0.3',
|
||||
port: 53,
|
||||
},
|
||||
destination: {
|
||||
ip: '192.168.0.3',
|
||||
port: 6343,
|
||||
},
|
||||
suricata: {
|
||||
eve: {
|
||||
flow_id: 4,
|
||||
proto: '',
|
||||
alert: {
|
||||
signature: 'ET PHONE HOME Stack Overflow (CVE-2019-90210)',
|
||||
signature_id: 4,
|
||||
__typename: 'level 2',
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
id: 4,
|
||||
name: 'jack.black',
|
||||
},
|
||||
geo: {
|
||||
region_name: 'neither',
|
||||
country_iso_code: 'sasquatch',
|
||||
},
|
||||
} as Ecs)
|
||||
)
|
||||
).toEqual({
|
||||
_id: '4',
|
||||
timestamp: '2018-11-08T19:03:25.937Z',
|
||||
host: {
|
||||
name: 'suricata',
|
||||
ip: ['192.168.0.1'],
|
||||
},
|
||||
event: {
|
||||
id: 4,
|
||||
category: 'Attempted Administrator Privilege Gain',
|
||||
type: 'Alert',
|
||||
module: 'suricata',
|
||||
severity: 1,
|
||||
},
|
||||
source: {
|
||||
ip: '192.168.0.3',
|
||||
port: 53,
|
||||
},
|
||||
destination: {
|
||||
ip: '192.168.0.3',
|
||||
port: 6343,
|
||||
},
|
||||
suricata: {
|
||||
eve: {
|
||||
flow_id: 4,
|
||||
proto: '',
|
||||
alert: {
|
||||
signature: 'ET PHONE HOME Stack Overflow (CVE-2019-90210)',
|
||||
signature_id: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
id: 4,
|
||||
name: 'jack.black',
|
||||
},
|
||||
geo: {
|
||||
region_name: 'neither',
|
||||
country_iso_code: 'sasquatch',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('it omits null and undefined values at arbitrary levels, for arbitrary data types', () => {
|
||||
expect(
|
||||
JSON.parse(
|
||||
stringifyEvent({
|
||||
_id: '4',
|
||||
timestamp: null,
|
||||
host: {
|
||||
name: null,
|
||||
ip: null,
|
||||
},
|
||||
event: {
|
||||
id: 4,
|
||||
category: 'theory',
|
||||
type: 'Alert',
|
||||
module: 'me',
|
||||
severity: 1,
|
||||
},
|
||||
source: {
|
||||
ip: undefined,
|
||||
port: 53,
|
||||
},
|
||||
destination: {
|
||||
ip: '192.168.0.3',
|
||||
port: 6343,
|
||||
},
|
||||
suricata: {
|
||||
eve: {
|
||||
flow_id: 4,
|
||||
proto: '',
|
||||
alert: {
|
||||
signature: 'dance moves',
|
||||
signature_id: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
id: 4,
|
||||
name: 'no use for a',
|
||||
},
|
||||
geo: {
|
||||
region_name: 'bizzaro',
|
||||
country_iso_code: 'world',
|
||||
},
|
||||
} as Ecs)
|
||||
)
|
||||
).toEqual({
|
||||
_id: '4',
|
||||
host: {},
|
||||
event: {
|
||||
id: 4,
|
||||
category: 'theory',
|
||||
type: 'Alert',
|
||||
module: 'me',
|
||||
severity: 1,
|
||||
},
|
||||
source: {
|
||||
port: 53,
|
||||
},
|
||||
destination: {
|
||||
ip: '192.168.0.3',
|
||||
port: 6343,
|
||||
},
|
||||
suricata: {
|
||||
eve: {
|
||||
flow_id: 4,
|
||||
proto: '',
|
||||
alert: {
|
||||
signature: 'dance moves',
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
id: 4,
|
||||
name: 'no use for a',
|
||||
},
|
||||
geo: {
|
||||
region_name: 'bizzaro',
|
||||
country_iso_code: 'world',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('eventHasNotes', () => {
|
||||
test('it returns false for when notes is empty', () => {
|
||||
expect(eventHasNotes([])).toEqual(false);
|
||||
});
|
||||
|
||||
test('it returns true for when notes is non-empty', () => {
|
||||
expect(
|
||||
eventHasNotes([
|
||||
{
|
||||
created: new Date(),
|
||||
id: 'required to purchase a beverage',
|
||||
lastEdit: new Date(),
|
||||
note: 'mind the gap',
|
||||
user: 'the.fresh.eyeballs',
|
||||
},
|
||||
])
|
||||
).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPinTooltip', () => {
|
||||
test('it informs the user the event may not be unpinned when the event is pinned and has notes', () => {
|
||||
expect(getPinTooltip({ isPinned: true, eventHasNotes: true })).toEqual(
|
||||
'This event cannot be unpinned because it has notes'
|
||||
);
|
||||
});
|
||||
|
||||
test('it tells the user the event is persisted when the event is pinned, but has no notes', () => {
|
||||
expect(getPinTooltip({ isPinned: true, eventHasNotes: false })).toEqual(
|
||||
'This event is persisted with the timeline'
|
||||
);
|
||||
});
|
||||
|
||||
test('it tells the user the event is NOT persisted when the event is not pinned, but it has notes', () => {
|
||||
expect(getPinTooltip({ isPinned: false, eventHasNotes: true })).toEqual(
|
||||
'This is event is NOT persisted with the timeline'
|
||||
);
|
||||
});
|
||||
|
||||
test('it tells the user the event is NOT persisted when the event is not pinned, and has no notes', () => {
|
||||
expect(getPinTooltip({ isPinned: false, eventHasNotes: false })).toEqual(
|
||||
'This is event is NOT persisted with the timeline'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('eventIsPinned', () => {
|
||||
test('returns true when the specified event id is contained in the pinnedEventIds', () => {
|
||||
const eventId = 'race-for-the-prize';
|
||||
const pinnedEventIds = { [eventId]: true, 'waiting-for-superman': true };
|
||||
|
||||
expect(eventIsPinned({ eventId, pinnedEventIds })).toEqual(true);
|
||||
});
|
||||
|
||||
test('returns false when the specified event id is NOT contained in the pinnedEventIds', () => {
|
||||
const eventId = 'safety-pin';
|
||||
const pinnedEventIds = { 'thumb-tack': true };
|
||||
|
||||
expect(eventIsPinned({ eventId, pinnedEventIds })).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { isEmpty, noop } from 'lodash/fp';
|
||||
|
||||
import { Ecs } from '../../../graphql/types';
|
||||
import { Note } from '../../../lib/note';
|
||||
import { OnPinEvent, OnUnPinEvent } from '../events';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export const ACTIONS_COLUMN_WIDTH = 100; // px;
|
||||
|
||||
// tslint:disable-next-line:no-any
|
||||
export const omitTypenameAndEmpty = (k: string, v: any): any | undefined =>
|
||||
k !== '__typename' && v != null ? v : undefined;
|
||||
|
||||
export const stringifyEvent = (ecs: Ecs): string => JSON.stringify(ecs, omitTypenameAndEmpty, 2);
|
||||
|
||||
export const eventHasNotes = (notes: Note[]): boolean => !isEmpty(notes);
|
||||
|
||||
export const getPinTooltip = ({
|
||||
isPinned,
|
||||
// tslint:disable-next-line:no-shadowed-variable
|
||||
eventHasNotes,
|
||||
}: {
|
||||
isPinned: boolean;
|
||||
eventHasNotes: boolean;
|
||||
}) => (isPinned && eventHasNotes ? i18n.PINNED_WITH_NOTES : isPinned ? i18n.PINNED : i18n.UNPINNED);
|
||||
|
||||
export interface IsPinnedParams {
|
||||
eventId: string;
|
||||
pinnedEventIds: { [eventId: string]: boolean };
|
||||
}
|
||||
|
||||
export const eventIsPinned = ({ eventId, pinnedEventIds }: IsPinnedParams): boolean =>
|
||||
pinnedEventIds[eventId] === true;
|
||||
|
||||
export interface GetPinOnClickParams {
|
||||
allowUnpinning: boolean;
|
||||
eventId: string;
|
||||
onPinEvent: OnPinEvent;
|
||||
onUnPinEvent: OnUnPinEvent;
|
||||
pinnedEventIds: { [eventId: string]: boolean };
|
||||
}
|
||||
|
||||
export const getPinOnClick = ({
|
||||
allowUnpinning,
|
||||
eventId,
|
||||
onPinEvent,
|
||||
onUnPinEvent,
|
||||
pinnedEventIds,
|
||||
}: GetPinOnClickParams): (() => void) => {
|
||||
if (!allowUnpinning) {
|
||||
return noop;
|
||||
}
|
||||
|
||||
return eventIsPinned({ eventId, pinnedEventIds })
|
||||
? () => onUnPinEvent(eventId)
|
||||
: () => onPinEvent(eventId);
|
||||
};
|
|
@ -4,138 +4,258 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EuiIcon, EuiText } from '@elastic/eui';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui';
|
||||
import * as React from 'react';
|
||||
import { pure } from 'recompose';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Ecs } from '../../../graphql/types';
|
||||
import { StatefulEventDetails } from '../../event_details/stateful_event_details';
|
||||
import { LazyAccordion } from '../../lazy_accordion';
|
||||
import { Note } from '../../../lib/note';
|
||||
import { AddNoteToEvent, UpdateNote } from '../../notes/helpers';
|
||||
import {
|
||||
OnColumnSorted,
|
||||
OnFilterChange,
|
||||
OnPinEvent,
|
||||
OnRangeSelected,
|
||||
OnUnPinEvent,
|
||||
} from '../events';
|
||||
import { ExpandableEvent } from '../expandable_event';
|
||||
import { footerHeight } from '../footer';
|
||||
import { Actions } from './actions';
|
||||
import { ColumnHeaders } from './column_headers';
|
||||
import { ColumnHeader } from './column_headers/column_header';
|
||||
import {
|
||||
ACTIONS_COLUMN_WIDTH,
|
||||
eventHasNotes,
|
||||
eventIsPinned,
|
||||
getPinOnClick,
|
||||
stringifyEvent,
|
||||
} from './helpers';
|
||||
import { ColumnRenderer, getColumnRenderer, getRowRenderer, RowRenderer } from './renderers';
|
||||
import { Sort } from './sort';
|
||||
|
||||
interface Props {
|
||||
addNoteToEvent: AddNoteToEvent;
|
||||
columnHeaders: ColumnHeader[];
|
||||
columnRenderers: ColumnRenderer[];
|
||||
data: Ecs[];
|
||||
height: number;
|
||||
id: string;
|
||||
notes: { [eventId: string]: Note[] };
|
||||
onColumnSorted: OnColumnSorted;
|
||||
onFilterChange: OnFilterChange;
|
||||
onPinEvent: OnPinEvent;
|
||||
onRangeSelected: OnRangeSelected;
|
||||
onUnPinEvent: OnUnPinEvent;
|
||||
pinnedEventIds: { [eventId: string]: boolean };
|
||||
range: string;
|
||||
rowRenderers: RowRenderer[];
|
||||
sort: Sort;
|
||||
updateNote: UpdateNote;
|
||||
}
|
||||
|
||||
const ScrollableArea = styled.div<{
|
||||
interface State {
|
||||
expanded: { [eventId: string]: boolean };
|
||||
showNotes: { [eventId: string]: boolean };
|
||||
}
|
||||
|
||||
const HorizontalScroll = styled.div<{
|
||||
height: number;
|
||||
}>`
|
||||
display: block;
|
||||
height: ${({ height }) => `${height}px`};
|
||||
overflow: auto;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
min-height: 0px;
|
||||
`;
|
||||
|
||||
const Row = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 0;
|
||||
min-height: 40px;
|
||||
`;
|
||||
|
||||
const FlexRow = styled.span`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
// NOTE: overflow-wrap: break-word is required below to render data that is too
|
||||
// long to fit in a cell, but has no breaks because, for example, it's a
|
||||
// single large value (e.g. 985205274836907)
|
||||
const Cell = styled(EuiText)`
|
||||
overflow: hidden;
|
||||
margin-right: 6px;
|
||||
overflow-wrap: break-word;
|
||||
`;
|
||||
|
||||
const TimeGutter = styled.span`
|
||||
min-width: 50px;
|
||||
background-color: ${props => props.theme.eui.euiColorLightShade};
|
||||
`;
|
||||
|
||||
const Pin = styled(EuiIcon)`
|
||||
min-width: 50px;
|
||||
margin-right: 8px;
|
||||
margin-top: 5px;
|
||||
transform: rotate(45deg);
|
||||
color: grey;
|
||||
`;
|
||||
|
||||
const DataDrivenColumns = styled.div`
|
||||
display: flex;
|
||||
margin-left: 5px;
|
||||
const VerticalScrollContainer = styled.div<{
|
||||
height: number;
|
||||
width: number;
|
||||
}>`
|
||||
display: block;
|
||||
height: ${({ height }) => `${height - footerHeight - 12}px`};
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
min-width: ${({ width }) => `${width}px`};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const ColumnRender = styled.div<{
|
||||
minwidth: string;
|
||||
maxwidth: string;
|
||||
const DataDrivenColumns = styled(EuiFlexGroup)`
|
||||
margin-left: 5px;
|
||||
`;
|
||||
|
||||
const Column = styled.div<{
|
||||
minWidth: string;
|
||||
maxWidth: string;
|
||||
index: number;
|
||||
}>`
|
||||
max-width: ${props => props.minwidth};
|
||||
min-width: ${props => props.maxwidth};
|
||||
background: ${props => (props.index % 2 !== 0 ? props.theme.eui.euiColorLightShade : 'inherit')};
|
||||
background: ${props => (props.index % 2 === 0 ? props.theme.eui.euiColorLightShade : 'inherit')};
|
||||
height: 100%;
|
||||
max-width: ${props => props.maxWidth};
|
||||
min-width: ${props => props.minWidth};
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
padding: 5px;
|
||||
`;
|
||||
|
||||
export const defaultWidth = 1090;
|
||||
|
||||
const ExpandableDetails = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
const emptyNotes: Note[] = [];
|
||||
|
||||
/** Renders the timeline body */
|
||||
export const Body = pure<Props>(
|
||||
({ columnHeaders, columnRenderers, data, height, id, rowRenderers }) => (
|
||||
<ScrollableArea height={height} data-test-subj="scrollableArea">
|
||||
{data.map(ecs => (
|
||||
<Row key={ecs._id!}>
|
||||
<TimeGutter />
|
||||
{getRowRenderer(ecs, rowRenderers).renderRow(
|
||||
ecs,
|
||||
<>
|
||||
<FlexRow>
|
||||
<Pin type="pin" size="l" />
|
||||
<DataDrivenColumns data-test-subj="dataDrivenColumns">
|
||||
{columnHeaders.map((header, index) => (
|
||||
<ColumnRender
|
||||
key={`cell-${header.id}`}
|
||||
data-test-subj="cellContainer"
|
||||
maxwidth={`${header.minWidth}px`}
|
||||
minwidth={`${header.minWidth}px`}
|
||||
index={index}
|
||||
>
|
||||
<Cell size="xs">
|
||||
{getColumnRenderer(header.id, columnRenderers, ecs).renderColumn(
|
||||
header.id,
|
||||
ecs
|
||||
)}
|
||||
</Cell>
|
||||
</ColumnRender>
|
||||
))}
|
||||
</DataDrivenColumns>
|
||||
</FlexRow>
|
||||
<FlexRow>
|
||||
<ExpandableDetails data-test-subj="expandableDetails">
|
||||
<LazyAccordion
|
||||
id={`timeline-${id}-row-${ecs._id}`}
|
||||
buttonContent={`${JSON.stringify(ecs.event) || {}}`} // TODO: this should be `event.message` or `event._source`
|
||||
paddingSize="none"
|
||||
>
|
||||
<StatefulEventDetails data={ecs} />
|
||||
</LazyAccordion>
|
||||
</ExpandableDetails>
|
||||
</FlexRow>
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
))}
|
||||
</ScrollableArea>
|
||||
)
|
||||
);
|
||||
export class Body extends React.PureComponent<Props, State> {
|
||||
public readonly state: State = {
|
||||
expanded: {},
|
||||
showNotes: {},
|
||||
};
|
||||
|
||||
public render() {
|
||||
const {
|
||||
addNoteToEvent,
|
||||
columnHeaders,
|
||||
columnRenderers,
|
||||
data,
|
||||
height,
|
||||
id,
|
||||
notes,
|
||||
onColumnSorted,
|
||||
onFilterChange,
|
||||
onPinEvent,
|
||||
onUnPinEvent,
|
||||
pinnedEventIds,
|
||||
rowRenderers,
|
||||
sort,
|
||||
updateNote,
|
||||
} = this.props;
|
||||
|
||||
const columnWidths = columnHeaders.reduce(
|
||||
(totalWidth, header) => totalWidth + header.minWidth,
|
||||
ACTIONS_COLUMN_WIDTH
|
||||
);
|
||||
|
||||
return (
|
||||
<HorizontalScroll data-test-subj="horizontal-scroll" height={height}>
|
||||
<ColumnHeaders
|
||||
actionsColumnWidth={ACTIONS_COLUMN_WIDTH}
|
||||
columnHeaders={columnHeaders}
|
||||
onColumnSorted={onColumnSorted}
|
||||
onFilterChange={onFilterChange}
|
||||
sort={sort}
|
||||
timelineId={id}
|
||||
/>
|
||||
<EuiHorizontalRule margin="xs" />
|
||||
|
||||
<VerticalScrollContainer
|
||||
data-test-subj="vertical-scroll-container"
|
||||
height={height}
|
||||
width={columnWidths}
|
||||
>
|
||||
<EuiFlexGroup data-test-subj="events" direction="column" gutterSize="none">
|
||||
{data.map(ecs => (
|
||||
<EuiFlexItem data-test-subj="event" grow={true} key={ecs._id!}>
|
||||
{getRowRenderer(ecs, rowRenderers).renderRow(
|
||||
ecs,
|
||||
<>
|
||||
<EuiFlexGroup data-test-subj="event-rows" direction="column" gutterSize="none">
|
||||
<EuiFlexItem data-test-subj="event-columns" grow={true}>
|
||||
<EuiFlexGroup data-test-subj="events" direction="row" gutterSize="none">
|
||||
<EuiFlexItem grow={false}>
|
||||
<Actions
|
||||
associateNote={this.associateNote(
|
||||
ecs._id!,
|
||||
addNoteToEvent,
|
||||
onPinEvent
|
||||
)}
|
||||
expanded={!!this.state.expanded[ecs._id!]}
|
||||
data-test-subj="timeline-row-actions"
|
||||
eventId={ecs._id!}
|
||||
eventIsPinned={eventIsPinned({
|
||||
eventId: ecs._id!,
|
||||
pinnedEventIds,
|
||||
})}
|
||||
notes={notes[ecs._id!] || emptyNotes}
|
||||
onEventToggled={this.onToggleExpanded(ecs._id!)}
|
||||
onPinClicked={getPinOnClick({
|
||||
allowUnpinning: !eventHasNotes(notes[ecs._id!]),
|
||||
eventId: ecs._id!,
|
||||
onPinEvent,
|
||||
onUnPinEvent,
|
||||
pinnedEventIds,
|
||||
})}
|
||||
showNotes={!!this.state.showNotes[ecs._id!]}
|
||||
toggleShowNotes={this.onToggleShowNotes(ecs._id!)}
|
||||
updateNote={updateNote}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={true}>
|
||||
<DataDrivenColumns
|
||||
data-test-subj="data-driven-columns"
|
||||
gutterSize="none"
|
||||
>
|
||||
{columnHeaders.map((header, index) => (
|
||||
<EuiFlexItem grow={true} key={header.id}>
|
||||
<Column
|
||||
data-test-subj="column"
|
||||
index={index}
|
||||
maxWidth="100%"
|
||||
minWidth={`${header.minWidth}px`}
|
||||
>
|
||||
{getColumnRenderer(
|
||||
header.id,
|
||||
columnRenderers,
|
||||
ecs
|
||||
).renderColumn(header.id, ecs)}
|
||||
</Column>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
</DataDrivenColumns>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem data-test-subj="event-details" grow={true}>
|
||||
<ExpandableEvent
|
||||
event={ecs}
|
||||
forceExpand={!!this.state.expanded[ecs._id!]}
|
||||
hideExpandButton={true}
|
||||
stringifiedEvent={stringifyEvent(ecs)}
|
||||
timelineId={id}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
</EuiFlexGroup>
|
||||
</VerticalScrollContainer>
|
||||
</HorizontalScroll>
|
||||
);
|
||||
}
|
||||
|
||||
private onToggleShowNotes = (eventId: string): (() => void) => () => {
|
||||
this.setState(state => ({
|
||||
showNotes: {
|
||||
...state.showNotes,
|
||||
[eventId]: !!!state.showNotes[eventId],
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
private onToggleExpanded = (eventId: string): (() => void) => () => {
|
||||
this.setState(state => ({
|
||||
expanded: {
|
||||
...state.expanded,
|
||||
[eventId]: !!!state.expanded[eventId],
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
private associateNote = (
|
||||
eventId: string,
|
||||
addNoteToEvent: AddNoteToEvent,
|
||||
onPinEvent: OnPinEvent
|
||||
): ((noteId: string) => void) => (noteId: string) => {
|
||||
addNoteToEvent({ eventId, noteId });
|
||||
onPinEvent(eventId); // pin the event, because it has notes
|
||||
};
|
||||
}
|
||||
|
|
|
@ -4,13 +4,18 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json';
|
||||
import { mount } from 'enzyme';
|
||||
import { cloneDeep, omit } from 'lodash/fp';
|
||||
import React from 'react';
|
||||
import { cloneDeep, noop, omit } from 'lodash/fp';
|
||||
import * as React from 'react';
|
||||
import { DragDropContext } from 'react-beautiful-dnd';
|
||||
import { Provider as ReduxStoreProvider } from 'react-redux';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
|
||||
import { columnRenderers } from '.';
|
||||
import { Ecs } from '../../../../graphql/types';
|
||||
import { mockEcsData } from '../../../../mock';
|
||||
import { createStore } from '../../../../store';
|
||||
import { getEmptyValue } from '../../../empty_value';
|
||||
import { getColumnRenderer } from './get_column_renderer';
|
||||
|
||||
|
@ -24,10 +29,19 @@ describe('get_column_renderer', () => {
|
|||
});
|
||||
|
||||
test('should render event id when dealing with data that is not suricata', () => {
|
||||
const store = createStore();
|
||||
const columnName = 'event.id';
|
||||
const columnRenderer = getColumnRenderer(columnName, columnRenderers, nonSuricata);
|
||||
const column = columnRenderer.renderColumn(columnName, nonSuricata);
|
||||
const wrapper = mount(<span>{column}</span>);
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<span>{column}</span>
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(wrapper.text()).toEqual('1');
|
||||
});
|
||||
|
||||
|
|
|
@ -4,35 +4,59 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json';
|
||||
import { mount } from 'enzyme';
|
||||
import React from 'react';
|
||||
import { noop } from 'lodash/fp';
|
||||
import * as React from 'react';
|
||||
import { DragDropContext } from 'react-beautiful-dnd';
|
||||
import { Provider as ReduxStoreProvider } from 'react-redux';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { rowRenderers } from '.';
|
||||
import { Ecs } from '../../../../graphql/types';
|
||||
import { mockEcsData } from '../../../../mock';
|
||||
import { createStore } from '../../../../store';
|
||||
import { getRowRenderer } from './get_row_renderer';
|
||||
|
||||
describe('get_column_renderer', () => {
|
||||
let nonSuricata: Ecs;
|
||||
let suricata: Ecs;
|
||||
let store = createStore();
|
||||
|
||||
beforeEach(() => {
|
||||
nonSuricata = cloneDeep(mockEcsData[0]);
|
||||
suricata = cloneDeep(mockEcsData[2]);
|
||||
store = createStore();
|
||||
});
|
||||
|
||||
test('should render plain row data when it is a non suricata row', () => {
|
||||
const rowRenderer = getRowRenderer(nonSuricata, rowRenderers);
|
||||
const row = rowRenderer.renderRow(nonSuricata, <span>some child</span>);
|
||||
const wrapper = mount(<span>{row}</span>);
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<span>{row}</span>
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(wrapper.text()).toContain('some child');
|
||||
});
|
||||
|
||||
test('should render a suricata row data when it is a suricata row', () => {
|
||||
const rowRenderer = getRowRenderer(suricata, rowRenderers);
|
||||
const row = rowRenderer.renderRow(suricata, <span>some child </span>);
|
||||
const wrapper = mount(<span>{row}</span>);
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<span>{row}</span>
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(wrapper.text()).toContain(
|
||||
'some child ET EXPLOIT NETGEAR WNR2000v5 hidden_lang_avi Stack Overflow (CVE-2016-10174)'
|
||||
);
|
||||
|
|
|
@ -4,18 +4,29 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json';
|
||||
import { mount } from 'enzyme';
|
||||
import { cloneDeep, omit } from 'lodash/fp';
|
||||
import React from 'react';
|
||||
import { cloneDeep, noop, omit } from 'lodash/fp';
|
||||
import * as React from 'react';
|
||||
import { DragDropContext } from 'react-beautiful-dnd';
|
||||
import { Provider as ReduxStoreProvider } from 'react-redux';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
|
||||
import moment = require('moment');
|
||||
import { plainColumnRenderer } from '.';
|
||||
import { Ecs } from '../../../../graphql/types';
|
||||
import { mockEcsData } from '../../../../mock';
|
||||
import { createStore } from '../../../../store';
|
||||
import { getEmptyValue } from '../../../empty_value';
|
||||
|
||||
describe('plain_column_renderer', () => {
|
||||
let mockDatum: Ecs;
|
||||
|
||||
let store = createStore();
|
||||
|
||||
beforeEach(() => {
|
||||
store = createStore();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockDatum = cloneDeep(mockEcsData[0]);
|
||||
});
|
||||
|
@ -35,111 +46,247 @@ describe('plain_column_renderer', () => {
|
|||
|
||||
test('should return the value of event.category if event.category has a valid value', () => {
|
||||
const column = plainColumnRenderer.renderColumn('event.category', mockDatum);
|
||||
const wrapper = mount(<span>{column}</span>);
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<span>{column}</span>
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(wrapper.text()).toEqual('Access');
|
||||
});
|
||||
|
||||
test('should return the value of destination.ip if destination.ip has a valid value', () => {
|
||||
const column = plainColumnRenderer.renderColumn('destination.ip', mockDatum);
|
||||
const wrapper = mount(<span>{column}</span>);
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<span>{column}</span>
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(wrapper.text()).toEqual('192.168.0.3');
|
||||
});
|
||||
|
||||
test('should return the value of event.id if event has a valid value', () => {
|
||||
const column = plainColumnRenderer.renderColumn('event.id', mockDatum);
|
||||
const wrapper = mount(<span>{column}</span>);
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<span>{column}</span>
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(wrapper.text()).toEqual('1');
|
||||
});
|
||||
|
||||
test('should return the value of geo.region_name if geo.region_name has a valid value', () => {
|
||||
const column = plainColumnRenderer.renderColumn('geo.region_name', mockDatum);
|
||||
const wrapper = mount(<span>{column}</span>);
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<span>{column}</span>
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(wrapper.text()).toEqual('xx');
|
||||
});
|
||||
|
||||
test('should return the value of event.severity if severity has a valid value', () => {
|
||||
const column = plainColumnRenderer.renderColumn('event.severity', mockDatum);
|
||||
const wrapper = mount(<span>{column}</span>);
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<span>{column}</span>
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(wrapper.text()).toEqual('3');
|
||||
});
|
||||
|
||||
test('should return the value of source.ip if source.ip has a valid value', () => {
|
||||
const column = plainColumnRenderer.renderColumn('source.ip', mockDatum);
|
||||
const wrapper = mount(<span>{column}</span>);
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<span>{column}</span>
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(wrapper.text()).toEqual('192.168.0.1');
|
||||
});
|
||||
|
||||
test('should return a formatted value of timestamp if timestamp has a valid value', () => {
|
||||
test('should return the (unformatted) time if timestamp has a valid value', () => {
|
||||
const column = plainColumnRenderer.renderColumn('timestamp', mockDatum);
|
||||
const wrapper = mount(<span>{column}</span>);
|
||||
expect(wrapper.text()).toEqual(moment(mockDatum.timestamp!).format());
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<span>{column}</span>
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(wrapper.text()).toEqual(mockDatum.timestamp!);
|
||||
});
|
||||
|
||||
test('should return the value of event.type if event.type has a valid value', () => {
|
||||
const column = plainColumnRenderer.renderColumn('event.type', mockDatum);
|
||||
const wrapper = mount(<span>{column}</span>);
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<span>{column}</span>
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(wrapper.text()).toEqual('HTTP Request');
|
||||
});
|
||||
|
||||
test('should return the of user.name if user.name has a valid value', () => {
|
||||
const column = plainColumnRenderer.renderColumn('user.name', mockDatum);
|
||||
const wrapper = mount(<span>{column}</span>);
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<span>{column}</span>
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(wrapper.text()).toEqual('john.dee');
|
||||
});
|
||||
|
||||
test('should return an empty value if event.category is empty', () => {
|
||||
const missingCategory = omit('event.category', mockDatum);
|
||||
const emptyColumn = plainColumnRenderer.renderColumn('event.category', missingCategory);
|
||||
const wrapper = mount(<span>{emptyColumn}</span>);
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<span>{emptyColumn}</span>
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(wrapper.text()).toEqual(getEmptyValue());
|
||||
});
|
||||
|
||||
test('should return an empty value if destination is empty', () => {
|
||||
const missingDestination = omit('destination', mockDatum);
|
||||
const emptyColumn = plainColumnRenderer.renderColumn('destination', missingDestination);
|
||||
const wrapper = mount(<span>{emptyColumn}</span>);
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<span>{emptyColumn}</span>
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(wrapper.text()).toEqual(getEmptyValue());
|
||||
});
|
||||
|
||||
test('should return an empty value if destination ip is empty', () => {
|
||||
const missingDestination = omit('destination.ip', mockDatum);
|
||||
const emptyColumn = plainColumnRenderer.renderColumn('destination.ip', missingDestination);
|
||||
const wrapper = mount(<span>{emptyColumn}</span>);
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<span>{emptyColumn}</span>
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(wrapper.text()).toEqual(getEmptyValue());
|
||||
});
|
||||
|
||||
test('should return an empty value if event severity is empty', () => {
|
||||
const missingSeverity = omit('event.severity', mockDatum);
|
||||
const emptyColumn = plainColumnRenderer.renderColumn('event.severity', missingSeverity);
|
||||
const wrapper = mount(<span>{emptyColumn}</span>);
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<span>{emptyColumn}</span>
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(wrapper.text()).toEqual(getEmptyValue());
|
||||
});
|
||||
|
||||
test('should return an empty value if source is empty', () => {
|
||||
const missingSource = omit('source', mockDatum);
|
||||
const emptyColumn = plainColumnRenderer.renderColumn('source', missingSource);
|
||||
const wrapper = mount(<span>{emptyColumn}</span>);
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<span>{emptyColumn}</span>
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(wrapper.text()).toEqual(getEmptyValue());
|
||||
});
|
||||
|
||||
test('should return an empty value if source.ip is empty', () => {
|
||||
const missingSource = omit('source.ip', mockDatum);
|
||||
const emptyColumn = plainColumnRenderer.renderColumn('source.ip', missingSource);
|
||||
const wrapper = mount(<span>{emptyColumn}</span>);
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<span>{emptyColumn}</span>
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(wrapper.text()).toEqual(getEmptyValue());
|
||||
});
|
||||
|
||||
test('should return an empty value if event.type is empty', () => {
|
||||
const missingType = omit('event.type', mockDatum);
|
||||
const emptyColumn = plainColumnRenderer.renderColumn('event.type', missingType);
|
||||
const wrapper = mount(<span>{emptyColumn}</span>);
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<span>{emptyColumn}</span>
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(wrapper.text()).toEqual(getEmptyValue());
|
||||
});
|
||||
|
||||
test('should return an empty value if user.name is empty', () => {
|
||||
const missingUser = omit('user.name', mockDatum);
|
||||
const emptyColumn = plainColumnRenderer.renderColumn('user.name', missingUser);
|
||||
const wrapper = mount(<span>{emptyColumn}</span>);
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<span>{emptyColumn}</span>
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(wrapper.text()).toEqual(getEmptyValue());
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,13 +4,19 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { getOr } from 'lodash/fp';
|
||||
import { has } from 'lodash/fp';
|
||||
import moment from 'moment';
|
||||
import React from 'react';
|
||||
|
||||
import { ColumnRenderer } from '.';
|
||||
import { Ecs } from '../../../../graphql/types';
|
||||
import { getMappedEcsValue, mappedEcsSchemaFieldNames } from '../../../../lib/ecs';
|
||||
import { escapeQueryValue } from '../../../../lib/keury';
|
||||
import { DragEffects, DraggableWrapper } from '../../../drag_and_drop/draggable_wrapper';
|
||||
import { escapeDataProviderId } from '../../../drag_and_drop/helpers';
|
||||
import { getOrEmptyTag } from '../../../empty_value';
|
||||
import { DateFieldWithTooltip } from '../../../event_details/helpers';
|
||||
import { Provider } from '../../data_providers/provider';
|
||||
|
||||
export const dataExistsAtColumn = (columnName: string, data: Ecs): boolean => has(columnName, data);
|
||||
|
||||
|
@ -18,10 +24,49 @@ export const plainColumnRenderer: ColumnRenderer = {
|
|||
isInstance: (columnName: string, ecs: Ecs) => dataExistsAtColumn(columnName, ecs),
|
||||
|
||||
renderColumn: (columnName: string, data: Ecs) => {
|
||||
return columnName !== 'timestamp' ? (
|
||||
getOrEmptyTag(columnName, data)
|
||||
) : (
|
||||
<>{moment(data!.timestamp!).format()}</>
|
||||
const itemDataProvider = {
|
||||
enabled: true,
|
||||
id: escapeDataProviderId(`id-timeline-column-${columnName}-for-event-${data._id!}`),
|
||||
name: `${columnName}: ${getMappedEcsValue({
|
||||
data,
|
||||
fieldName: columnName,
|
||||
})}`,
|
||||
queryMatch: {
|
||||
field: getOr(columnName, columnName, mappedEcsSchemaFieldNames),
|
||||
value: escapeQueryValue(
|
||||
getMappedEcsValue({
|
||||
data,
|
||||
fieldName: columnName,
|
||||
})
|
||||
),
|
||||
},
|
||||
excluded: false,
|
||||
kqlQuery: '',
|
||||
and: [],
|
||||
};
|
||||
|
||||
return (
|
||||
<DraggableWrapper
|
||||
key={`timeline-draggable-column-${columnName}-for-event-${data._id!}`}
|
||||
dataProvider={itemDataProvider}
|
||||
render={(dataProvider, _, snapshot) =>
|
||||
snapshot.isDragging ? (
|
||||
<DragEffects>
|
||||
<Provider dataProvider={dataProvider} />
|
||||
</DragEffects>
|
||||
) : (
|
||||
<>
|
||||
{columnName !== 'timestamp' ? (
|
||||
getOrEmptyTag(columnName, data)
|
||||
) : (
|
||||
<DateFieldWithTooltip
|
||||
dateString={getMappedEcsValue({ data, fieldName: columnName })!}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -4,8 +4,10 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json';
|
||||
import { mount } from 'enzyme';
|
||||
import React from 'react';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { plainRowRenderer } from '.';
|
||||
|
@ -24,7 +26,11 @@ describe('plain_row_renderer', () => {
|
|||
|
||||
test('should render a plain row', () => {
|
||||
const children = plainRowRenderer.renderRow(mockDatum, <span>some children</span>);
|
||||
const wrapper = mount(<span>{children}</span>);
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<span>{children}</span>
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(wrapper.text()).toEqual('some children');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -12,11 +12,8 @@ import { Ecs } from '../../../../graphql/types';
|
|||
|
||||
const PlainRow = styled.div`
|
||||
width: 100%;
|
||||
border-color: transparent;
|
||||
&:hover {
|
||||
border: 1px solid;
|
||||
border-color: #d9d9d9;
|
||||
box-shadow: 0 2px 2px -1px rgba(153, 153, 153, 0.3), 0 1px 5px -2px rgba(153, 153, 153, 0.3);
|
||||
border: 1px solid ${props => props.theme.eui.euiColorMediumShade};
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
|
@ -4,21 +4,28 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json';
|
||||
import { mount } from 'enzyme';
|
||||
import { cloneDeep, omit } from 'lodash/fp';
|
||||
import React from 'react';
|
||||
import { cloneDeep, noop, omit } from 'lodash/fp';
|
||||
import * as React from 'react';
|
||||
import { DragDropContext } from 'react-beautiful-dnd';
|
||||
import { Provider as ReduxStoreProvider } from 'react-redux';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
|
||||
import { suricataRowRenderer } from '.';
|
||||
import { Ecs } from '../../../../graphql/types';
|
||||
import { mockEcsData } from '../../../../mock';
|
||||
import { createStore } from '../../../../store';
|
||||
|
||||
describe('plain_row_renderer', () => {
|
||||
describe('suricata_row_renderer', () => {
|
||||
let nonSuricata: Ecs;
|
||||
let suricata: Ecs;
|
||||
let store = createStore();
|
||||
|
||||
beforeEach(() => {
|
||||
nonSuricata = cloneDeep(mockEcsData[0]);
|
||||
suricata = cloneDeep(mockEcsData[2]);
|
||||
store = createStore();
|
||||
});
|
||||
|
||||
test('should return false if not a suricata datum', () => {
|
||||
|
@ -31,13 +38,29 @@ describe('plain_row_renderer', () => {
|
|||
|
||||
test('should render children normally if it does not have a signature', () => {
|
||||
const children = suricataRowRenderer.renderRow(nonSuricata, <span>some children</span>);
|
||||
const wrapper = mount(<span>{children}</span>);
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<span>{children}</span>
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(wrapper.text()).toEqual('some children');
|
||||
});
|
||||
|
||||
test('should render a suricata row', () => {
|
||||
const children = suricataRowRenderer.renderRow(suricata, <span>some children </span>);
|
||||
const wrapper = mount(<span>{children}</span>);
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<span>{children}</span>
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(wrapper.text()).toContain(
|
||||
'some children ET EXPLOIT NETGEAR WNR2000v5 hidden_lang_avi Stack Overflow (CVE-2016-10174)'
|
||||
);
|
||||
|
@ -46,7 +69,15 @@ describe('plain_row_renderer', () => {
|
|||
test('should render a suricata row even if it does not have a suricata signature', () => {
|
||||
const withoutSignature = omit('suricata.eve.alert.signature', suricata);
|
||||
const children = suricataRowRenderer.renderRow(withoutSignature, <span>some children</span>);
|
||||
const wrapper = mount(<span>{children}</span>);
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<DragDropContext onDragEnd={noop}>
|
||||
<span>{children}</span>
|
||||
</DragDropContext>
|
||||
</ReduxStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(wrapper.text()).toEqual('some children');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,12 +5,18 @@
|
|||
*/
|
||||
|
||||
import { EuiButton } from '@elastic/eui';
|
||||
import { get } from 'lodash/fp';
|
||||
import { get, getOr } from 'lodash/fp';
|
||||
import React from 'react';
|
||||
import { pure } from 'recompose';
|
||||
import styled, { keyframes } from 'styled-components';
|
||||
|
||||
import { createLinkWithSignature, RowRenderer } from '.';
|
||||
import { Ecs } from '../../../../graphql/types';
|
||||
import { getMappedEcsValue, mappedEcsSchemaFieldNames } from '../../../../lib/ecs';
|
||||
import { escapeQueryValue } from '../../../../lib/keury';
|
||||
import { DragEffects, DraggableWrapper } from '../../../drag_and_drop/draggable_wrapper';
|
||||
import { escapeDataProviderId } from '../../../drag_and_drop/helpers';
|
||||
import { Provider } from '../../data_providers/provider';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export const dropInEffect = keyframes`
|
||||
|
@ -38,20 +44,11 @@ export const dropInEffect = keyframes`
|
|||
|
||||
const SuricataRow = styled.div`
|
||||
width: 100%;
|
||||
border-color: transparent;
|
||||
border-top: 1px solid #98a2b3;
|
||||
border-right: 1px solid #98a2b3;
|
||||
border-bottom: 1px solid #98a2b3;
|
||||
border-left: 2px solid #8ecce3;
|
||||
overflow: hidden;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
margin-left: -1px;
|
||||
&:hover {
|
||||
border: 1px solid;
|
||||
border-color: #d9d9d9;
|
||||
border-left: 2px solid #8ecce3;
|
||||
box-shadow: 0 2px 2px -1px rgba(153, 153, 153, 0.3), 0 1px 5px -2px rgba(153, 153, 153, 0.3);
|
||||
border: 1px solid ${props => props.theme.eui.euiColorMediumShade};
|
||||
}
|
||||
`;
|
||||
|
||||
|
@ -80,18 +77,48 @@ const Label = styled.div`
|
|||
font-weight: bold;
|
||||
`;
|
||||
|
||||
interface LabelValuePairParams {
|
||||
label: string;
|
||||
ariaLabel: string;
|
||||
value: string;
|
||||
}
|
||||
const DraggableValue = pure<{ data: Ecs; fieldName: string }>(({ data, fieldName }) => {
|
||||
const itemDataProvider = {
|
||||
enabled: true,
|
||||
id: escapeDataProviderId(`id-suricata-row-render-value-for-${fieldName}-${data._id!}`),
|
||||
name: `${fieldName}: ${getMappedEcsValue({
|
||||
data,
|
||||
fieldName,
|
||||
})}`,
|
||||
queryMatch: {
|
||||
field: getOr(fieldName, fieldName, mappedEcsSchemaFieldNames),
|
||||
value: escapeQueryValue(
|
||||
getMappedEcsValue({
|
||||
data,
|
||||
fieldName,
|
||||
})
|
||||
),
|
||||
},
|
||||
excluded: false,
|
||||
kqlQuery: '',
|
||||
and: [],
|
||||
};
|
||||
|
||||
const LabelValuePair = ({ label, ariaLabel, value }: LabelValuePairParams) => (
|
||||
<LabelValuePairContainer>
|
||||
<Label>{label}</Label>
|
||||
<div aria-label={ariaLabel}>{value}</div>
|
||||
</LabelValuePairContainer>
|
||||
);
|
||||
return (
|
||||
<DraggableWrapper
|
||||
key={`suricata-row-render-value-for-${fieldName}-${data._id!}`}
|
||||
dataProvider={itemDataProvider}
|
||||
render={(dataProvider, _, snapshot) =>
|
||||
snapshot.isDragging ? (
|
||||
<DragEffects>
|
||||
<Provider dataProvider={dataProvider} />
|
||||
</DragEffects>
|
||||
) : (
|
||||
<>{`${getMappedEcsValue({ data, fieldName })}`}</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const ValuesContainer = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
export const suricataRowRenderer: RowRenderer = {
|
||||
isInstance: (ecs: Ecs) => {
|
||||
|
@ -107,21 +134,32 @@ export const suricataRowRenderer: RowRenderer = {
|
|||
{children}
|
||||
{signature != null ? (
|
||||
<SuricataSignature>
|
||||
<EuiButton fill size="s" href={createLinkWithSignature(signature)} target="_blank">
|
||||
<EuiButton
|
||||
key={data._id!}
|
||||
fill
|
||||
size="s"
|
||||
href={createLinkWithSignature(signature)}
|
||||
target="_blank"
|
||||
>
|
||||
{signature}
|
||||
</EuiButton>
|
||||
<Details>
|
||||
<LabelValuePair label={i18n.PROTOCOL} ariaLabel={i18n.PROTOCOL} value={i18n.TCP} />
|
||||
<LabelValuePair
|
||||
label={i18n.SOURCE}
|
||||
ariaLabel={i18n.SOURCE}
|
||||
value={`${data.source!.ip}:${data.source!.port}`}
|
||||
/>
|
||||
<LabelValuePair
|
||||
label={i18n.DESTINATION}
|
||||
ariaLabel={i18n.DESTINATION}
|
||||
value={`${data.destination!.ip}:${data.destination!.port}`}
|
||||
/>
|
||||
<LabelValuePairContainer>
|
||||
<Label>{i18n.SOURCE}</Label>
|
||||
<ValuesContainer>
|
||||
<DraggableValue data={data} fieldName={'source.ip'} />
|
||||
{':'}
|
||||
<DraggableValue data={data} fieldName={'source.port'} />
|
||||
</ValuesContainer>
|
||||
</LabelValuePairContainer>
|
||||
<LabelValuePairContainer>
|
||||
<Label>{i18n.DESTINATION}</Label>
|
||||
<ValuesContainer>
|
||||
<DraggableValue data={data} fieldName={'destination.ip'} />
|
||||
{':'}
|
||||
<DraggableValue data={data} fieldName={'destination.port'} />
|
||||
</ValuesContainer>
|
||||
</LabelValuePairContainer>
|
||||
</Details>
|
||||
</SuricataSignature>
|
||||
) : null}
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const NOTES_TOOLTIP = i18n.translate('xpack.secops.timeline.body.notes.tooltip', {
|
||||
defaultMessage: 'Add or view notes for this event',
|
||||
});
|
||||
|
||||
export const COPY_TO_CLIPBOARD = i18n.translate('xpack.secops.timeline.body.copy.to.clipboard', {
|
||||
defaultMessage: 'Copy to Clipboard',
|
||||
});
|
||||
|
||||
export const EVENT = i18n.translate('xpack.secops.timeline.body.event', {
|
||||
defaultMessage: 'Event',
|
||||
});
|
||||
|
||||
export const UNPINNED = i18n.translate('xpack.secops.timeline.body.pinning.unpinnedTooltip', {
|
||||
defaultMessage: 'This is event is NOT persisted with the timeline',
|
||||
});
|
||||
|
||||
export const PINNED = i18n.translate('xpack.secops.timeline.body.pinning.pinnnedTooltip', {
|
||||
defaultMessage: 'This event is persisted with the timeline',
|
||||
});
|
||||
|
||||
export const PINNED_WITH_NOTES = i18n.translate(
|
||||
'xpack.secops.timeline.body.pinning.pinnnedWithNotesTooltip',
|
||||
{
|
||||
defaultMessage: 'This event cannot be unpinned because it has notes',
|
||||
}
|
||||
);
|
||||
|
||||
export const EXPAND = i18n.translate('xpack.secops.timeline.body.actions.expand', {
|
||||
defaultMessage: 'Expand',
|
||||
});
|
||||
|
||||
export const COLLAPSE = i18n.translate('xpack.secops.timeline.body.actions.collapse', {
|
||||
defaultMessage: 'Collapse',
|
||||
});
|
|
@ -4,16 +4,17 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EuiBadge } from '@elastic/eui';
|
||||
import { EuiBadge, EuiText } from '@elastic/eui';
|
||||
import * as React from 'react';
|
||||
import { pure } from 'recompose';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { AndOrBadge } from '../../and_or_badge';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const Text = styled.div`
|
||||
color: #999999;
|
||||
const Text = styled(EuiText)`
|
||||
overflow: hidden;
|
||||
margin: 5px 0 5px 0;
|
||||
padding: 3px;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
@ -25,13 +26,6 @@ const BadgeHighlighted = styled(EuiBadge)`
|
|||
min-width: 70px;
|
||||
`;
|
||||
|
||||
const BadgeOr = styled(EuiBadge)`
|
||||
height: 20px;
|
||||
margin: 0 5px 0 5px;
|
||||
max-width: 20px;
|
||||
min-width: 20px;
|
||||
`;
|
||||
|
||||
const EmptyContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
|
@ -60,14 +54,14 @@ const NoWrap = styled.div`
|
|||
export const Empty = pure(() => (
|
||||
<EmptyContainer className="timeline-drop-area" data-test-subj="empty">
|
||||
<NoWrap>
|
||||
<Text>{i18n.DROP_ANYTHING}</Text>
|
||||
<Text color="subdued">{i18n.DROP_ANYTHING}</Text>
|
||||
<BadgeHighlighted color="#d9d9d9">{i18n.HIGHLIGHTED}</BadgeHighlighted>
|
||||
</NoWrap>
|
||||
|
||||
<NoWrap>
|
||||
<Text>{i18n.HERE_TO_BUILD_AN}</Text>
|
||||
<BadgeOr color="#d9d9d9">{i18n.OR.toLocaleUpperCase()}</BadgeOr>
|
||||
<Text>{i18n.QUERY}</Text>
|
||||
<Text color="subdued">{i18n.HERE_TO_BUILD_AN}</Text>
|
||||
<AndOrBadge type="or" />
|
||||
<Text color="subdued">{i18n.QUERY}</Text>
|
||||
</NoWrap>
|
||||
</EmptyContainer>
|
||||
));
|
||||
|
|
|
@ -39,8 +39,7 @@ const DropTargetDataProviders = styled.div`
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
margin: 5px;
|
||||
padding: 0px;
|
||||
margin: 5px 0 5px 0;
|
||||
min-height: 100px;
|
||||
overflow-y: auto;
|
||||
background-color: ${props => props.theme.eui.euiFormBackgroundColor};
|
||||
|
|
|
@ -27,6 +27,7 @@ interface OwnProps {
|
|||
|
||||
const MyEuiPopover = styled(EuiPopover)`
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
export const getProviderActions = (
|
||||
|
@ -99,7 +100,9 @@ export const ProviderItemActions = pure<OwnProps>(
|
|||
anchorPosition="downCenter"
|
||||
panelPaddingSize="none"
|
||||
>
|
||||
<EuiContextMenu initialPanelId={0} panels={panelTree} data-test-subj="providerActions" />
|
||||
<div style={{ userSelect: 'none' }}>
|
||||
<EuiContextMenu initialPanelId={0} panels={panelTree} data-test-subj="providerActions" />
|
||||
</div>
|
||||
</MyEuiPopover>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -10,12 +10,12 @@ import {
|
|||
EuiButtonEmpty,
|
||||
EuiButtonEmptyProps,
|
||||
EuiContextMenu,
|
||||
EuiHorizontalRule,
|
||||
EuiPopover,
|
||||
} from '@elastic/eui';
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { AndOrBadge } from '../../and_or_badge';
|
||||
import {
|
||||
OnChangeDataProviderKqlQuery,
|
||||
OnDataProviderRemoved,
|
||||
|
@ -25,28 +25,17 @@ import {
|
|||
import { DataProvider } from './data_provider';
|
||||
import { ProviderBadge } from './provider_badge';
|
||||
import { getProviderActions } from './provider_item_actions';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const NumberProviderAndBadge = styled(EuiBadge)`
|
||||
margin: 0px 5px;
|
||||
`;
|
||||
|
||||
const EuiBadgeAndStyled = styled(EuiBadge)`
|
||||
position: absolute;
|
||||
left: calc(50% - 15px);
|
||||
top: -18px;
|
||||
z-index: 1;
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
padding: 8px 3px 0px 3px;
|
||||
border-radius: 100%;
|
||||
`;
|
||||
|
||||
const AndStyled = styled.div`
|
||||
position: relative;
|
||||
.euiHorizontalRule {
|
||||
margin: 28px 0px;
|
||||
}
|
||||
const AndContianer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
margin: 5px 0 5px 0;
|
||||
`;
|
||||
|
||||
const EuiButtonContent = styled.div`
|
||||
|
@ -99,7 +88,7 @@ export class ProviderItemAndPopover extends React.PureComponent<
|
|||
{dataProvidersAnd.length}
|
||||
</NumberProviderAndBadge>
|
||||
)}
|
||||
<EuiBadgeAndStyled color="hollow">{i18n.AND}</EuiBadgeAndStyled>
|
||||
<AndOrBadge type="and" />
|
||||
</EuiButtonContent>
|
||||
</EuiButtonEmpty>
|
||||
);
|
||||
|
@ -145,10 +134,9 @@ export class ProviderItemAndPopover extends React.PureComponent<
|
|||
<EuiContextMenu initialPanelId={0} panels={panelTree} />
|
||||
</EuiAccordion>
|
||||
{index < dataProvidersAnd.length - 1 && (
|
||||
<AndStyled>
|
||||
<EuiBadgeAndStyled color="hollow">{i18n.AND}</EuiBadgeAndStyled>
|
||||
<EuiHorizontalRule />
|
||||
</AndStyled>
|
||||
<AndContianer>
|
||||
<AndOrBadge type="and" />
|
||||
</AndContianer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -5,7 +5,6 @@
|
|||
*/
|
||||
|
||||
import {
|
||||
EuiBadge,
|
||||
// @ts-ignore
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
|
@ -16,6 +15,7 @@ import { Draggable } from 'react-beautiful-dnd';
|
|||
import { pure } from 'recompose';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { AndOrBadge } from '../../and_or_badge';
|
||||
import {
|
||||
OnChangeDataProviderKqlQuery,
|
||||
OnChangeDroppableAndProvider,
|
||||
|
@ -27,7 +27,6 @@ import { DataProvider } from './data_provider';
|
|||
import { Empty } from './empty';
|
||||
import { ProviderItemAndDragDrop } from './provider_item_and_drag_drop';
|
||||
import { ProviderItemBadge } from './provider_item_badge';
|
||||
import * as i18n from './translations';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
|
@ -43,19 +42,15 @@ const PanelProviders = styled.div`
|
|||
display: flex;
|
||||
flex-direction: row;
|
||||
min-height: 100px;
|
||||
padding: 10px;
|
||||
padding: 5px 10px 5px 10px;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const EuiBadgeOrStyled = styled(EuiBadge)`
|
||||
const EuiBadgeOrStyled = styled.div`
|
||||
position: absolute;
|
||||
right: -37px;
|
||||
right: -42px;
|
||||
top: 27px;
|
||||
z-index: 1;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 7px 6px 4px 6px;
|
||||
border-radius: 100%;
|
||||
`;
|
||||
|
||||
const PanelProvidersGroupContainer = styled(EuiFlexGroup)`
|
||||
|
@ -170,7 +165,9 @@ export const Providers = pure<Props>(
|
|||
</EuiFlexGroup>
|
||||
</PanelProviderItemContainer>
|
||||
<PanelProviderItemContainer grow={false}>
|
||||
<EuiBadgeOrStyled color="hollow">{i18n.OR.toLocaleUpperCase()}</EuiBadgeOrStyled>
|
||||
<EuiBadgeOrStyled>
|
||||
<AndOrBadge type="or" />
|
||||
</EuiBadgeOrStyled>
|
||||
<EuiHorizontalRule />
|
||||
</PanelProviderItemContainer>
|
||||
</PanelProvidersGroupContainer>
|
||||
|
|
|
@ -62,3 +62,9 @@ export type OnChangeItemsPerPage = (itemsPerPage: number) => void;
|
|||
export type OnLoadMore = (cursor: string, tieBreaker: string) => void;
|
||||
|
||||
export type OnChangeDroppableAndProvider = (providerId: string) => void;
|
||||
|
||||
/** Invoked when a user pins an event */
|
||||
export type OnPinEvent = (eventId: string) => void;
|
||||
|
||||
/** Invoked when a user unpins an event */
|
||||
export type OnUnPinEvent = (eventId: string) => void;
|
||||
|
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EuiPanel, EuiToolTip } from '@elastic/eui';
|
||||
import * as React from 'react';
|
||||
import { pure } from 'recompose';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Ecs } from '../../../graphql/types';
|
||||
import { WithCopyToClipboard } from '../../../lib/clipboard/with_copy_to_clipboard';
|
||||
import { StatefulEventDetails } from '../../event_details/stateful_event_details';
|
||||
import { LazyAccordion } from '../../lazy_accordion';
|
||||
import { WithHoverActions } from '../../with_hover_actions';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const EventWithHoverActions = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const ExpandableDetails = styled.div<{ hideExpandButton: boolean }>`
|
||||
width: 100%;
|
||||
|
||||
${({ hideExpandButton }) =>
|
||||
hideExpandButton
|
||||
? `
|
||||
.euiAccordion__button svg {
|
||||
width: 0px;
|
||||
height: 0px;
|
||||
}
|
||||
`
|
||||
: ''}
|
||||
`;
|
||||
|
||||
const HoverActionsRelativeContainer = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const HoverActionsContainer = styled(EuiPanel)`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 25px;
|
||||
justify-content: center;
|
||||
left: -65px;
|
||||
position: absolute;
|
||||
top: -30px;
|
||||
width: 30px;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
event: Ecs;
|
||||
forceExpand?: boolean;
|
||||
hideExpandButton?: boolean;
|
||||
stringifiedEvent: string;
|
||||
timelineId: string;
|
||||
}
|
||||
|
||||
export const ExpandableEvent = pure<Props>(
|
||||
({ event, forceExpand = false, hideExpandButton = false, stringifiedEvent, timelineId }) => (
|
||||
<ExpandableDetails
|
||||
data-test-subj="timeline-expandable-details"
|
||||
hideExpandButton={hideExpandButton}
|
||||
>
|
||||
<LazyAccordion
|
||||
id={`timeline-${timelineId}-row-${event._id}`}
|
||||
buttonContent={
|
||||
<WithHoverActions
|
||||
render={showHoverContent => (
|
||||
<EventWithHoverActions>
|
||||
<HoverActionsRelativeContainer>
|
||||
{showHoverContent ? (
|
||||
<HoverActionsContainer data-test-subj="hover-actions-container">
|
||||
<EuiToolTip content={i18n.COPY_TO_CLIPBOARD}>
|
||||
<WithCopyToClipboard text={stringifiedEvent} titleSummary={i18n.EVENT} />
|
||||
</EuiToolTip>
|
||||
</HoverActionsContainer>
|
||||
) : null}
|
||||
</HoverActionsRelativeContainer>
|
||||
</EventWithHoverActions>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
forceExpand={forceExpand}
|
||||
paddingSize="none"
|
||||
>
|
||||
<StatefulEventDetails data={event} />
|
||||
</LazyAccordion>
|
||||
</ExpandableDetails>
|
||||
)
|
||||
);
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const COPY_TO_CLIPBOARD = i18n.translate(
|
||||
'xpack.secops.timeline.expandableEvent.copyToClipboardToolTip',
|
||||
{
|
||||
defaultMessage: 'Copy to Clipboard',
|
||||
}
|
||||
);
|
||||
|
||||
export const EVENT = i18n.translate('xpack.secops.timeline.expandableEvent.eventToolTipTitle', {
|
||||
defaultMessage: 'Event',
|
||||
});
|
|
@ -2,10 +2,6 @@
|
|||
|
||||
exports[`Footer Timeline Component rendering it renders the default timeline footer 1`] = `
|
||||
<Fragment>
|
||||
<EuiHorizontalRule
|
||||
margin="xs"
|
||||
size="full"
|
||||
/>
|
||||
<styled.div
|
||||
data-test-subj="timeline-footer"
|
||||
height={100}
|
||||
|
@ -23,133 +19,70 @@ exports[`Footer Timeline Component rendering it renders the default timeline foo
|
|||
component="div"
|
||||
grow={false}
|
||||
>
|
||||
<EuiFlexGroup
|
||||
alignItems="flexStart"
|
||||
component="div"
|
||||
direction="column"
|
||||
gutterSize="none"
|
||||
justifyContent="flexStart"
|
||||
responsive={true}
|
||||
wrap={false}
|
||||
<styled.div
|
||||
data-test-subj="timeline-event-count"
|
||||
>
|
||||
<EuiFlexItem
|
||||
component="div"
|
||||
grow={true}
|
||||
>
|
||||
<styled.div
|
||||
data-test-subj="timeline-event-count"
|
||||
>
|
||||
<pure(Component)
|
||||
itemsCount={2}
|
||||
serverSideEventCount={15546}
|
||||
/>
|
||||
</styled.div>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
component="div"
|
||||
grow={true}
|
||||
>
|
||||
<Styled(EuiPopover)
|
||||
button={
|
||||
<EuiButtonEmpty
|
||||
color="text"
|
||||
iconSide="right"
|
||||
iconType="arrowDown"
|
||||
<pure(Component)
|
||||
closePopover={[Function]}
|
||||
isOpen={false}
|
||||
items={
|
||||
Array [
|
||||
<EuiContextMenuItem
|
||||
icon="empty"
|
||||
layoutAlign="center"
|
||||
onClick={[Function]}
|
||||
size="s"
|
||||
type="button"
|
||||
toolTipPosition="right"
|
||||
>
|
||||
Rows:
|
||||
2
|
||||
</EuiButtonEmpty>
|
||||
}
|
||||
className="footer-popover"
|
||||
closePopover={[Function]}
|
||||
data-test-subj="timelineSizeRowPopover"
|
||||
id="customizablePagination"
|
||||
isOpen={false}
|
||||
panelPaddingSize="none"
|
||||
>
|
||||
<EuiContextMenuPanel
|
||||
data-test-subj="timelinePickSizeRow"
|
||||
hasFocus={true}
|
||||
items={
|
||||
Array [
|
||||
<EuiContextMenuItem
|
||||
icon="empty"
|
||||
layoutAlign="center"
|
||||
onClick={[Function]}
|
||||
toolTipPosition="right"
|
||||
>
|
||||
1 rows
|
||||
</EuiContextMenuItem>,
|
||||
<EuiContextMenuItem
|
||||
icon="empty"
|
||||
layoutAlign="center"
|
||||
onClick={[Function]}
|
||||
toolTipPosition="right"
|
||||
>
|
||||
5 rows
|
||||
</EuiContextMenuItem>,
|
||||
<EuiContextMenuItem
|
||||
icon="empty"
|
||||
layoutAlign="center"
|
||||
onClick={[Function]}
|
||||
toolTipPosition="right"
|
||||
>
|
||||
10 rows
|
||||
</EuiContextMenuItem>,
|
||||
<EuiContextMenuItem
|
||||
icon="empty"
|
||||
layoutAlign="center"
|
||||
onClick={[Function]}
|
||||
toolTipPosition="right"
|
||||
>
|
||||
20 rows
|
||||
</EuiContextMenuItem>,
|
||||
]
|
||||
}
|
||||
/>
|
||||
</Styled(EuiPopover)>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
1 rows
|
||||
</EuiContextMenuItem>,
|
||||
<EuiContextMenuItem
|
||||
icon="empty"
|
||||
layoutAlign="center"
|
||||
onClick={[Function]}
|
||||
toolTipPosition="right"
|
||||
>
|
||||
5 rows
|
||||
</EuiContextMenuItem>,
|
||||
<EuiContextMenuItem
|
||||
icon="empty"
|
||||
layoutAlign="center"
|
||||
onClick={[Function]}
|
||||
toolTipPosition="right"
|
||||
>
|
||||
10 rows
|
||||
</EuiContextMenuItem>,
|
||||
<EuiContextMenuItem
|
||||
icon="empty"
|
||||
layoutAlign="center"
|
||||
onClick={[Function]}
|
||||
toolTipPosition="right"
|
||||
>
|
||||
20 rows
|
||||
</EuiContextMenuItem>,
|
||||
]
|
||||
}
|
||||
itemsCount={2}
|
||||
onClick={[Function]}
|
||||
serverSideEventCount={15546}
|
||||
/>
|
||||
</styled.div>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
component="div"
|
||||
grow={false}
|
||||
>
|
||||
<EuiFlexGroup
|
||||
alignItems="flexStart"
|
||||
component="div"
|
||||
direction="row"
|
||||
gutterSize="none"
|
||||
justifyContent="center"
|
||||
responsive={true}
|
||||
wrap={false}
|
||||
>
|
||||
<EuiFlexItem
|
||||
component="div"
|
||||
grow={false}
|
||||
>
|
||||
<EuiButton
|
||||
color="primary"
|
||||
data-test-subj="TimelineMoreButton"
|
||||
fill={false}
|
||||
iconSide="left"
|
||||
isLoading={false}
|
||||
onClick={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Load More
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<pure(Component)
|
||||
hasNextPage={true}
|
||||
isLoading={false}
|
||||
loadMore={[Function]}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
component="div"
|
||||
grow={false}
|
||||
>
|
||||
<LastUpdatedAt
|
||||
short={false}
|
||||
updatedAt={1546878704036}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
|
|
@ -17,6 +17,7 @@ import { mockData } from './mock';
|
|||
describe('Footer Timeline Component', () => {
|
||||
const loadMore = jest.fn();
|
||||
const onChangeItemsPerPage = jest.fn();
|
||||
const width = 500;
|
||||
|
||||
describe('rendering', () => {
|
||||
test('it renders the default timeline footer', () => {
|
||||
|
@ -35,6 +36,7 @@ describe('Footer Timeline Component', () => {
|
|||
nextCursor={getOr(null, 'endCursor.value', mockData.Events.pageInfo)!}
|
||||
tieBreaker={getOr(null, 'endCursor.tiebreaker', mockData.Events.pageInfo)!}
|
||||
updatedAt={1546878704036}
|
||||
width={width}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -57,6 +59,7 @@ describe('Footer Timeline Component', () => {
|
|||
nextCursor={getOr(null, 'endCursor.value', mockData.Events.pageInfo)!}
|
||||
tieBreaker={getOr(null, 'endCursor.tiebreaker', mockData.Events.pageInfo)!}
|
||||
updatedAt={1546878704036}
|
||||
width={width}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -79,6 +82,7 @@ describe('Footer Timeline Component', () => {
|
|||
nextCursor={getOr(null, 'endCursor.value', mockData.Events.pageInfo)!}
|
||||
tieBreaker={getOr(null, 'endCursor.tiebreaker', mockData.Events.pageInfo)!}
|
||||
updatedAt={1546878704036}
|
||||
width={width}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -102,6 +106,7 @@ describe('Footer Timeline Component', () => {
|
|||
nextCursor={getOr(null, 'endCursor.value', mockData.Events.pageInfo)!}
|
||||
tieBreaker={getOr(null, 'endCursor.tiebreaker', mockData.Events.pageInfo)!}
|
||||
updatedAt={1546878704036}
|
||||
width={width}
|
||||
/>
|
||||
</I18nProvider>
|
||||
);
|
||||
|
@ -126,6 +131,7 @@ describe('Footer Timeline Component', () => {
|
|||
nextCursor={getOr(null, 'endCursor.value', mockData.Events.pageInfo)!}
|
||||
tieBreaker={getOr(null, 'endCursor.tiebreaker', mockData.Events.pageInfo)!}
|
||||
updatedAt={1546878704036}
|
||||
width={width}
|
||||
/>
|
||||
</I18nProvider>
|
||||
);
|
||||
|
@ -159,6 +165,7 @@ describe('Footer Timeline Component', () => {
|
|||
nextCursor={getOr(null, 'endCursor.value', mockData.Events.pageInfo)!}
|
||||
tieBreaker={getOr(null, 'endCursor.tiebreaker', mockData.Events.pageInfo)!}
|
||||
updatedAt={1546878704036}
|
||||
width={width}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -182,6 +189,7 @@ describe('Footer Timeline Component', () => {
|
|||
nextCursor={getOr(null, 'endCursor.value', mockData.Events.pageInfo)!}
|
||||
tieBreaker={getOr(null, 'endCursor.tiebreaker', mockData.Events.pageInfo)!}
|
||||
updatedAt={1546878704036}
|
||||
width={width}
|
||||
/>
|
||||
</I18nProvider>
|
||||
);
|
||||
|
@ -192,54 +200,6 @@ describe('Footer Timeline Component', () => {
|
|||
.simulate('click');
|
||||
expect(wrapper.find('[data-test-subj="timelinePickSizeRow"]').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it does NOT render popover to select new itemsPerPage in timeline if there is not enough data for it ', () => {
|
||||
const wrapper = mount(
|
||||
<I18nProvider>
|
||||
<Footer
|
||||
dataProviders={mockDataProviders}
|
||||
serverSideEventCount={2}
|
||||
hasNextPage={getOr(false, 'hasNextPage', mockData.Events.pageInfo)!}
|
||||
height={100}
|
||||
isLoading={false}
|
||||
itemsCount={mockData.Events.edges.length}
|
||||
itemsPerPage={2}
|
||||
itemsPerPageOptions={[1, 5, 10, 20]}
|
||||
onChangeItemsPerPage={onChangeItemsPerPage}
|
||||
onLoadMore={loadMore}
|
||||
nextCursor={getOr(null, 'endCursor.value', mockData.Events.pageInfo)!}
|
||||
tieBreaker={getOr(null, 'endCursor.tiebreaker', mockData.Events.pageInfo)!}
|
||||
updatedAt={1546878704036}
|
||||
/>
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="timelineSizeRowPopover"]').exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('it will NOT render popover to select new itemsPerPage in timeline if props itemsPerPageOptions is empty', () => {
|
||||
const wrapper = mount(
|
||||
<I18nProvider>
|
||||
<Footer
|
||||
dataProviders={mockDataProviders}
|
||||
serverSideEventCount={mockData.Events.totalCount}
|
||||
hasNextPage={getOr(false, 'hasNextPage', mockData.Events.pageInfo)!}
|
||||
height={100}
|
||||
isLoading={false}
|
||||
itemsCount={mockData.Events.edges.length}
|
||||
itemsPerPage={2}
|
||||
itemsPerPageOptions={[]}
|
||||
onChangeItemsPerPage={onChangeItemsPerPage}
|
||||
onLoadMore={loadMore}
|
||||
nextCursor={getOr(null, 'endCursor.value', mockData.Events.pageInfo)!}
|
||||
tieBreaker={getOr(null, 'endCursor.tiebreaker', mockData.Events.pageInfo)!}
|
||||
updatedAt={1546878704036}
|
||||
/>
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="timelineSizeRowPopover"]').exists()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Events', () => {
|
||||
|
@ -260,6 +220,7 @@ describe('Footer Timeline Component', () => {
|
|||
nextCursor={getOr(null, 'endCursor.value', mockData.Events.pageInfo)!}
|
||||
tieBreaker={getOr(null, 'endCursor.tiebreaker', mockData.Events.pageInfo)!}
|
||||
updatedAt={1546878704036}
|
||||
width={width}
|
||||
/>
|
||||
</I18nProvider>
|
||||
);
|
||||
|
@ -289,6 +250,7 @@ describe('Footer Timeline Component', () => {
|
|||
nextCursor={getOr(null, 'endCursor.value', mockData.Events.pageInfo)!}
|
||||
tieBreaker={getOr(null, 'endCursor.tiebreaker', mockData.Events.pageInfo)!}
|
||||
updatedAt={1546878704036}
|
||||
width={width}
|
||||
/>
|
||||
</I18nProvider>
|
||||
);
|
||||
|
|
|
@ -12,7 +12,6 @@ import {
|
|||
EuiContextMenuPanel,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiHorizontalRule,
|
||||
EuiPopover,
|
||||
EuiToolTip,
|
||||
} from '@elastic/eui';
|
||||
|
@ -26,6 +25,10 @@ import { OnChangeItemsPerPage, OnLoadMore } from '../events';
|
|||
import { LastUpdatedAt } from './last_updated';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const PagingContainer = styled.div`
|
||||
padding: 0 10px 0 10px;
|
||||
`;
|
||||
|
||||
interface FooterProps {
|
||||
dataProviders: DataProvider[];
|
||||
itemsCount: number;
|
||||
|
@ -40,6 +43,7 @@ interface FooterProps {
|
|||
serverSideEventCount: number;
|
||||
tieBreaker: string;
|
||||
updatedAt: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface FooterState {
|
||||
|
@ -48,19 +52,77 @@ interface FooterState {
|
|||
}
|
||||
|
||||
/** The height of the footer, exported for use in height calculations */
|
||||
export const footerHeight = 50; // px
|
||||
export const footerHeight = 55; // px
|
||||
|
||||
export const ServerSideEventCount = styled.div`
|
||||
margin: 0 5px 0 5px;
|
||||
`;
|
||||
|
||||
/** Displays the server-side count of events */
|
||||
export const EventsCount = pure<{ serverSideEventCount: number; itemsCount: number }>(
|
||||
({ itemsCount, serverSideEventCount }) => (
|
||||
<EuiToolTip content={i18n.TOTAL_COUNT_OF_EVENTS}>
|
||||
<h5>
|
||||
<EuiBadge color="hollow">{itemsCount}</EuiBadge> {i18n.OF}{' '}
|
||||
export const EventsCount = pure<{
|
||||
closePopover: () => void;
|
||||
isOpen: boolean;
|
||||
items: React.ReactNode[];
|
||||
itemsCount: number;
|
||||
onClick: () => void;
|
||||
serverSideEventCount: number;
|
||||
}>(({ closePopover, isOpen, items, itemsCount, onClick, serverSideEventCount }) => (
|
||||
<h5>
|
||||
<PopoverRowItems
|
||||
className="footer-popover"
|
||||
id="customizablePagination"
|
||||
data-test-subj="timelineSizeRowPopover"
|
||||
button={
|
||||
<>
|
||||
<EuiBadge color="hollow">
|
||||
{itemsCount}
|
||||
<EuiButtonEmpty
|
||||
size="s"
|
||||
color="text"
|
||||
iconType="arrowDown"
|
||||
iconSide="right"
|
||||
onClick={onClick}
|
||||
/>
|
||||
</EuiBadge>
|
||||
{` ${i18n.OF} `}
|
||||
</>
|
||||
}
|
||||
isOpen={isOpen}
|
||||
closePopover={closePopover}
|
||||
panelPaddingSize="none"
|
||||
>
|
||||
<EuiContextMenuPanel items={items} data-test-subj="timelinePickSizeRow" />
|
||||
</PopoverRowItems>
|
||||
<EuiToolTip content={`${serverSideEventCount} ${i18n.TOTAL_COUNT_OF_EVENTS}`}>
|
||||
<ServerSideEventCount>
|
||||
<EuiBadge color="hollow">{serverSideEventCount}</EuiBadge> {i18n.EVENTS}
|
||||
</h5>
|
||||
</ServerSideEventCount>
|
||||
</EuiToolTip>
|
||||
)
|
||||
);
|
||||
</h5>
|
||||
));
|
||||
|
||||
export const PagingControl = pure<{
|
||||
hasNextPage: boolean;
|
||||
isLoading: boolean;
|
||||
loadMore: () => void;
|
||||
}>(({ hasNextPage, isLoading, loadMore }) => (
|
||||
<>
|
||||
{hasNextPage && (
|
||||
<PagingContainer>
|
||||
<EuiButton
|
||||
data-test-subj="TimelineMoreButton"
|
||||
isLoading={isLoading}
|
||||
onClick={loadMore}
|
||||
size="s"
|
||||
>
|
||||
{isLoading ? `${i18n.LOADING}...` : i18n.LOAD_MORE}
|
||||
</EuiButton>
|
||||
</PagingContainer>
|
||||
)}
|
||||
</>
|
||||
));
|
||||
|
||||
export const shortLastUpdated = (width: number): boolean => width < 500;
|
||||
|
||||
const SpinnerAndEventCount = styled.div`
|
||||
display: flex;
|
||||
|
@ -71,7 +133,11 @@ const SpinnerAndEventCount = styled.div`
|
|||
const FooterContainer = styled.div<{ height: number }>`
|
||||
height: ${({ height }) => height}px;
|
||||
max-height: ${({ height }) => height}px;
|
||||
overflow: hidden;
|
||||
padding-top: 4px;
|
||||
text-overflow: ellipsis;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const PopoverRowItems = styled(EuiPopover)`
|
||||
|
@ -110,36 +176,28 @@ export class Footer extends React.PureComponent<FooterProps, FooterState> {
|
|||
serverSideEventCount,
|
||||
hasNextPage,
|
||||
updatedAt,
|
||||
width,
|
||||
} = this.props;
|
||||
|
||||
if (isLoading && !this.state.paginationLoading) {
|
||||
return (
|
||||
<LoadingPanel
|
||||
height="auto"
|
||||
width="100%"
|
||||
text={`${i18n.LOADING_TIMELINE_DATA}...`}
|
||||
data-test-subj="LoadingPanelTimeline"
|
||||
/>
|
||||
<>
|
||||
<LoadingPanel
|
||||
data-test-subj="LoadingPanelTimeline"
|
||||
height="35px"
|
||||
showBorder={false}
|
||||
text={`${i18n.LOADING_TIMELINE_DATA}...`}
|
||||
width="100%"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const button = (
|
||||
<EuiButtonEmpty
|
||||
size="s"
|
||||
color="text"
|
||||
iconType="arrowDown"
|
||||
iconSide="right"
|
||||
onClick={this.onButtonClick}
|
||||
>
|
||||
Rows: {itemsPerPage}
|
||||
</EuiButtonEmpty>
|
||||
);
|
||||
|
||||
const rowItems =
|
||||
itemsPerPageOptions &&
|
||||
itemsPerPageOptions.map(item => (
|
||||
<EuiContextMenuItem
|
||||
key={`${item}-timeline-rows`}
|
||||
key={item}
|
||||
icon={itemsPerPage === item ? 'check' : 'empty'}
|
||||
onClick={() => {
|
||||
this.closePopover();
|
||||
|
@ -151,7 +209,6 @@ export class Footer extends React.PureComponent<FooterProps, FooterState> {
|
|||
));
|
||||
return (
|
||||
<>
|
||||
<EuiHorizontalRule margin="xs" />
|
||||
{dataProviders.length !== 0 && (
|
||||
<FooterContainer height={height} data-test-subj="timeline-footer">
|
||||
<EuiFlexGroup
|
||||
|
@ -161,62 +218,26 @@ export class Footer extends React.PureComponent<FooterProps, FooterState> {
|
|||
direction="row"
|
||||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup
|
||||
gutterSize="none"
|
||||
alignItems="flexStart"
|
||||
justifyContent="flexStart"
|
||||
direction="column"
|
||||
>
|
||||
<EuiFlexItem>
|
||||
<SpinnerAndEventCount data-test-subj="timeline-event-count">
|
||||
<EventsCount
|
||||
itemsCount={itemsCount}
|
||||
serverSideEventCount={serverSideEventCount}
|
||||
/>
|
||||
</SpinnerAndEventCount>
|
||||
</EuiFlexItem>
|
||||
{serverSideEventCount > itemsPerPage && rowItems.length > 0 && (
|
||||
<EuiFlexItem>
|
||||
<PopoverRowItems
|
||||
className="footer-popover"
|
||||
id="customizablePagination"
|
||||
data-test-subj="timelineSizeRowPopover"
|
||||
button={button}
|
||||
isOpen={this.state.isPopoverOpen}
|
||||
closePopover={this.closePopover}
|
||||
panelPaddingSize="none"
|
||||
>
|
||||
<EuiContextMenuPanel
|
||||
items={rowItems}
|
||||
data-test-subj="timelinePickSizeRow"
|
||||
/>
|
||||
</PopoverRowItems>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
<SpinnerAndEventCount data-test-subj="timeline-event-count">
|
||||
<EventsCount
|
||||
closePopover={this.closePopover}
|
||||
isOpen={this.state.isPopoverOpen}
|
||||
items={rowItems}
|
||||
itemsCount={itemsCount}
|
||||
onClick={this.onButtonClick}
|
||||
serverSideEventCount={serverSideEventCount}
|
||||
/>
|
||||
</SpinnerAndEventCount>
|
||||
</EuiFlexItem>
|
||||
{hasNextPage && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup
|
||||
gutterSize="none"
|
||||
alignItems="flexStart"
|
||||
justifyContent="center"
|
||||
direction="row"
|
||||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
data-test-subj="TimelineMoreButton"
|
||||
isLoading={isLoading}
|
||||
onClick={this.loadMore}
|
||||
>
|
||||
{isLoading ? 'Loading...' : 'Load More'}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem grow={false}>
|
||||
<LastUpdatedAt updatedAt={updatedAt} />
|
||||
<PagingControl
|
||||
hasNextPage={hasNextPage}
|
||||
isLoading={isLoading}
|
||||
loadMore={this.loadMore}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<LastUpdatedAt updatedAt={updatedAt} short={shortLastUpdated(width)} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</FooterContainer>
|
||||
|
|
|
@ -4,12 +4,15 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiToolTip } from '@elastic/eui';
|
||||
import { FormattedRelative } from '@kbn/i18n/react';
|
||||
import * as React from 'react';
|
||||
import { pure } from 'recompose';
|
||||
|
||||
import * as i18n from './translations';
|
||||
|
||||
interface LastUpdatedAtProps {
|
||||
short?: boolean;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
|
@ -17,11 +20,24 @@ interface LastUpdatedAtState {
|
|||
date: number;
|
||||
}
|
||||
|
||||
export const Updated = pure<{ date: number; prefix: string; updatedAt: number }>(
|
||||
({ date, prefix, updatedAt }) => (
|
||||
<>
|
||||
{prefix}
|
||||
{
|
||||
<FormattedRelative
|
||||
data-test-subj="last-updated-at-date"
|
||||
key={`formatedRelative-${date}`}
|
||||
value={new Date(updatedAt)}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
)
|
||||
);
|
||||
|
||||
export class LastUpdatedAt extends React.PureComponent<LastUpdatedAtProps, LastUpdatedAtState> {
|
||||
public readonly state = {
|
||||
date: Date.now(),
|
||||
updateInterval: 0,
|
||||
update: false,
|
||||
};
|
||||
private timerID?: NodeJS.Timeout;
|
||||
|
||||
|
@ -40,20 +56,35 @@ export class LastUpdatedAt extends React.PureComponent<LastUpdatedAtProps, LastU
|
|||
}
|
||||
|
||||
public render() {
|
||||
const { short = false } = this.props;
|
||||
const prefix = ` ${i18n.UPDATED} `;
|
||||
|
||||
return (
|
||||
<EuiFlexGroup gutterSize="none" alignItems="center" justifyContent="flexEnd" direction="row">
|
||||
<EuiFlexGroup
|
||||
alignItems="center"
|
||||
data-test-subj="last-updated-at-container"
|
||||
direction="row"
|
||||
gutterSize="none"
|
||||
justifyContent="flexEnd"
|
||||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type="clock" />
|
||||
<EuiIcon data-test-subj="last-updated-at-clock-icon" type="clock" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
{' '}
|
||||
{i18n.UPDATED}{' '}
|
||||
{
|
||||
<FormattedRelative
|
||||
key={`FormatedTime-${this.state.date}`}
|
||||
value={new Date(this.props.updatedAt)}
|
||||
<EuiToolTip
|
||||
data-test-subj="timeline-stream-tool-tip"
|
||||
content={
|
||||
<>
|
||||
<Updated date={this.state.date} prefix={prefix} updatedAt={this.props.updatedAt} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Updated
|
||||
date={this.state.date}
|
||||
prefix={!short ? prefix : ''}
|
||||
updatedAt={this.props.updatedAt}
|
||||
/>
|
||||
}
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
|
|
|
@ -22,8 +22,16 @@ export const ROWS = i18n.translate('xpack.secops.footer.rows', {
|
|||
defaultMessage: 'rows',
|
||||
});
|
||||
|
||||
export const LOADING = i18n.translate('xpack.secops.footer.loadingLabel', {
|
||||
defaultMessage: 'Loading',
|
||||
});
|
||||
|
||||
export const LOAD_MORE = i18n.translate('xpack.secops.footer.loadMoreLabel', {
|
||||
defaultMessage: 'Load More',
|
||||
});
|
||||
|
||||
export const TOTAL_COUNT_OF_EVENTS = i18n.translate('xpack.secops.footer.totalCountOfEvents', {
|
||||
defaultMessage: 'The total count of events matching the search criteria',
|
||||
defaultMessage: 'events match the search criteria',
|
||||
});
|
||||
|
||||
export const UPDATED = i18n.translate('xpack.secops.footer.updated', {
|
||||
|
|
|
@ -37,17 +37,12 @@ describe('Header', () => {
|
|||
<TimelineHeader
|
||||
id="foo"
|
||||
indexPattern={indexPattern}
|
||||
columnHeaders={[]}
|
||||
dataProviders={mockDataProviders}
|
||||
onChangeDataProviderKqlQuery={noop}
|
||||
onChangeDroppableAndProvider={noop}
|
||||
onColumnSorted={noop}
|
||||
onDataProviderRemoved={noop}
|
||||
onFilterChange={noop}
|
||||
onRangeSelected={noop}
|
||||
onToggleDataProviderEnabled={noop}
|
||||
onToggleDataProviderExcluded={noop}
|
||||
range="1 Day"
|
||||
show={true}
|
||||
sort={{
|
||||
columnId: 'timestamp',
|
||||
|
|
|
@ -4,43 +4,31 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EuiHorizontalRule } from '@elastic/eui';
|
||||
import * as React from 'react';
|
||||
import { pure } from 'recompose';
|
||||
import styled from 'styled-components';
|
||||
import { StaticIndexPattern } from 'ui/index_patterns';
|
||||
|
||||
import { ColumnHeaders } from '../body/column_headers';
|
||||
import { ColumnHeader } from '../body/column_headers/column_header';
|
||||
import { Sort } from '../body/sort';
|
||||
import { DataProviders } from '../data_providers';
|
||||
import { DataProvider } from '../data_providers/data_provider';
|
||||
import {
|
||||
OnChangeDataProviderKqlQuery,
|
||||
OnChangeDroppableAndProvider,
|
||||
OnColumnSorted,
|
||||
OnDataProviderRemoved,
|
||||
OnFilterChange,
|
||||
OnRangeSelected,
|
||||
OnToggleDataProviderEnabled,
|
||||
OnToggleDataProviderExcluded,
|
||||
} from '../events';
|
||||
import { StatefulSearchOrFilter } from '../search_or_filter';
|
||||
|
||||
interface Props {
|
||||
columnHeaders: ColumnHeader[];
|
||||
id: string;
|
||||
indexPattern: StaticIndexPattern;
|
||||
dataProviders: DataProvider[];
|
||||
onChangeDataProviderKqlQuery: OnChangeDataProviderKqlQuery;
|
||||
onChangeDroppableAndProvider: OnChangeDroppableAndProvider;
|
||||
onColumnSorted: OnColumnSorted;
|
||||
onDataProviderRemoved: OnDataProviderRemoved;
|
||||
onFilterChange: OnFilterChange;
|
||||
onRangeSelected: OnRangeSelected;
|
||||
onToggleDataProviderEnabled: OnToggleDataProviderEnabled;
|
||||
onToggleDataProviderExcluded: OnToggleDataProviderExcluded;
|
||||
range: string;
|
||||
show: boolean;
|
||||
sort: Sort;
|
||||
}
|
||||
|
@ -51,19 +39,14 @@ const TimelineHeaderContainer = styled.div`
|
|||
|
||||
export const TimelineHeader = pure<Props>(
|
||||
({
|
||||
columnHeaders,
|
||||
id,
|
||||
indexPattern,
|
||||
dataProviders,
|
||||
onChangeDataProviderKqlQuery,
|
||||
onChangeDroppableAndProvider,
|
||||
onColumnSorted,
|
||||
onDataProviderRemoved,
|
||||
onFilterChange,
|
||||
onRangeSelected,
|
||||
onToggleDataProviderEnabled,
|
||||
onToggleDataProviderExcluded,
|
||||
range,
|
||||
show,
|
||||
sort,
|
||||
}) => (
|
||||
|
@ -79,15 +62,6 @@ export const TimelineHeader = pure<Props>(
|
|||
show={show}
|
||||
/>
|
||||
<StatefulSearchOrFilter timelineId={id} indexPattern={indexPattern} />
|
||||
<ColumnHeaders
|
||||
columnHeaders={columnHeaders}
|
||||
onColumnSorted={onColumnSorted}
|
||||
onFilterChange={onFilterChange}
|
||||
onRangeSelected={onRangeSelected}
|
||||
range={range}
|
||||
sort={sort}
|
||||
/>
|
||||
<EuiHorizontalRule margin="xs" />
|
||||
</TimelineHeaderContainer>
|
||||
)
|
||||
);
|
||||
|
|
|
@ -6,8 +6,9 @@
|
|||
|
||||
import { cloneDeep } from 'lodash/fp';
|
||||
import { mockIndexPattern } from '../../mock';
|
||||
import { NotesById } from '../../store/local/app/model';
|
||||
import { mockDataProviders } from './data_providers/mock/mock_data_providers';
|
||||
import { buildGlobalQuery, combineQueries } from './helpers';
|
||||
import { buildGlobalQuery, combineQueries, getEventNotes } from './helpers';
|
||||
|
||||
const cleanUpKqlQuery = (str: string) => str.replace(/\n/g, '').replace(/\s\s+/g, ' ');
|
||||
|
||||
|
@ -75,4 +76,69 @@ describe('Combined Queries', () => {
|
|||
'{"bool":{"should":[{"bool":{"filter":[{"bool":{"should":[{"match":{"name":"Provider 1"}}],"minimum_should_match":1}},{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521830963132}}}],"minimum_should_match":1}},{"bool":{"should":[{"range":{"@timestamp":{"lte":1521862432253}}}],"minimum_should_match":1}}]}}]}},{"bool":{"should":[{"match_phrase":{"host.name":"host-1"}}],"minimum_should_match":1}}],"minimum_should_match":1}}'
|
||||
);
|
||||
});
|
||||
|
||||
describe('getEventNotes', () => {
|
||||
test('it returns the expected notes for all events in eventIdToNoteIds that have notes associated with them', () => {
|
||||
const sameDate = new Date();
|
||||
const eventIdToNoteIds: { [eventId: string]: string[] } = {
|
||||
a: ['123'],
|
||||
b: [],
|
||||
c: ['does-not-exist', '10', '11'],
|
||||
d: ['also-does-not-exist'],
|
||||
};
|
||||
const notesById: NotesById = {
|
||||
'123': {
|
||||
created: sameDate,
|
||||
id: '123',
|
||||
lastEdit: sameDate,
|
||||
note: 'you can count',
|
||||
user: 'sesame.st',
|
||||
},
|
||||
'10': {
|
||||
created: sameDate,
|
||||
id: '10',
|
||||
lastEdit: sameDate,
|
||||
note: 'on two hands',
|
||||
user: 'monkey',
|
||||
},
|
||||
'11': {
|
||||
created: sameDate,
|
||||
id: '11',
|
||||
lastEdit: sameDate,
|
||||
note: 'extra',
|
||||
user: 'finger',
|
||||
},
|
||||
};
|
||||
|
||||
expect(getEventNotes({ eventIdToNoteIds, notesById })).toEqual({
|
||||
a: [
|
||||
{
|
||||
created: sameDate,
|
||||
id: '123',
|
||||
lastEdit: sameDate,
|
||||
note: 'you can count',
|
||||
user: 'sesame.st',
|
||||
},
|
||||
],
|
||||
b: [],
|
||||
c: [
|
||||
{
|
||||
created: sameDate,
|
||||
id: '10',
|
||||
lastEdit: sameDate,
|
||||
note: 'on two hands',
|
||||
user: 'monkey',
|
||||
},
|
||||
{
|
||||
created: sameDate,
|
||||
id: '11',
|
||||
lastEdit: sameDate,
|
||||
note: 'extra',
|
||||
user: 'finger',
|
||||
},
|
||||
],
|
||||
d: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,6 +8,9 @@ import { isEmpty, isNumber } from 'lodash/fp';
|
|||
import { StaticIndexPattern } from 'ui/index_patterns';
|
||||
|
||||
import { convertKueryToElasticSearchQuery, escapeQueryValue } from '../../lib/keury';
|
||||
import { Note } from '../../lib/note';
|
||||
import { getApplicableNotes } from '../../lib/note/helpers';
|
||||
import { NotesById } from '../../store/local/app/model';
|
||||
import { DataProvider } from './data_providers/data_provider';
|
||||
|
||||
const buildQueryMatch = (dataProvider: DataProvider) =>
|
||||
|
@ -75,6 +78,21 @@ export const combineQueries = (
|
|||
};
|
||||
};
|
||||
|
||||
export const getEventNotes = ({
|
||||
eventIdToNoteIds,
|
||||
notesById,
|
||||
}: {
|
||||
eventIdToNoteIds: { [eventId: string]: string[] };
|
||||
notesById: NotesById;
|
||||
}): { [eventId: string]: Note[] } =>
|
||||
Object.keys(eventIdToNoteIds).reduce(
|
||||
(acc, eventId) => ({
|
||||
...acc,
|
||||
[eventId]: getApplicableNotes({ noteIds: eventIdToNoteIds[eventId], notesById }),
|
||||
}),
|
||||
{}
|
||||
);
|
||||
|
||||
interface CalculateBodyHeightParams {
|
||||
/** The the height of the flyout container, which is typically the entire "page", not including the standard Kibana navigation */
|
||||
flyoutHeight?: number;
|
||||
|
|
|
@ -11,8 +11,18 @@ import { ActionCreator } from 'typescript-fsa';
|
|||
|
||||
import { WithSource } from '../../containers/source';
|
||||
import { IndexType } from '../../graphql/types';
|
||||
import { State, timelineActions, timelineModel, timelineSelectors } from '../../store';
|
||||
import { Note } from '../../lib/note';
|
||||
import {
|
||||
appActions,
|
||||
appSelectors,
|
||||
State,
|
||||
timelineActions,
|
||||
timelineModel,
|
||||
timelineSelectors,
|
||||
} from '../../store';
|
||||
import { AddNoteToEvent, UpdateNote } from '../notes/helpers';
|
||||
import { ColumnHeader } from './body/column_headers/column_header';
|
||||
import { defaultHeaders } from './body/column_headers/headers';
|
||||
import { columnRenderers, rowRenderers } from './body/renderers';
|
||||
import { Sort } from './body/sort';
|
||||
import { DataProvider } from './data_providers/data_provider';
|
||||
|
@ -22,37 +32,51 @@ import {
|
|||
OnChangeItemsPerPage,
|
||||
OnColumnSorted,
|
||||
OnDataProviderRemoved,
|
||||
OnPinEvent,
|
||||
OnRangeSelected,
|
||||
OnToggleDataProviderEnabled,
|
||||
OnToggleDataProviderExcluded,
|
||||
OnUnPinEvent,
|
||||
} from './events';
|
||||
import { getEventNotes } from './helpers';
|
||||
import { Timeline } from './timeline';
|
||||
|
||||
export interface OwnProps {
|
||||
id: string;
|
||||
flyoutHeaderHeight: number;
|
||||
flyoutHeight: number;
|
||||
headers: ColumnHeader[];
|
||||
}
|
||||
|
||||
interface StateReduxProps {
|
||||
activePage?: number;
|
||||
dataProviders?: DataProvider[];
|
||||
headers?: ColumnHeader[];
|
||||
itemsPerPage?: number;
|
||||
itemsPerPageOptions?: number[];
|
||||
kqlQueryExpression: string;
|
||||
notes?: { [eventId: string]: Note[] };
|
||||
pageCount?: number;
|
||||
pinnedEventIds?: { [eventId: string]: boolean };
|
||||
range?: string;
|
||||
sort?: Sort;
|
||||
show?: boolean;
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
addNoteToEvent?: ActionCreator<{ id: string; noteId: string; eventId: string }>;
|
||||
createTimeline?: ActionCreator<{ id: string }>;
|
||||
addProvider?: ActionCreator<{
|
||||
id: string;
|
||||
provider: DataProvider;
|
||||
}>;
|
||||
pinEvent?: ActionCreator<{
|
||||
id: string;
|
||||
eventId: string;
|
||||
}>;
|
||||
unPinEvent?: ActionCreator<{
|
||||
id: string;
|
||||
eventId: string;
|
||||
}>;
|
||||
updateProviders?: ActionCreator<{
|
||||
id: string;
|
||||
providers: DataProvider[];
|
||||
|
@ -91,6 +115,7 @@ interface DispatchProps {
|
|||
id: string;
|
||||
itemsPerPage: number;
|
||||
}>;
|
||||
updateNote?: ActionCreator<{ note: Note }>;
|
||||
updateItemsPerPageOptions?: ActionCreator<{
|
||||
id: string;
|
||||
itemsPerPageOptions: number[];
|
||||
|
@ -116,6 +141,7 @@ class StatefulTimelineComponent extends React.PureComponent<Props> {
|
|||
|
||||
public render() {
|
||||
const {
|
||||
addNoteToEvent,
|
||||
dataProviders,
|
||||
flyoutHeight,
|
||||
flyoutHeaderHeight,
|
||||
|
@ -124,10 +150,15 @@ class StatefulTimelineComponent extends React.PureComponent<Props> {
|
|||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
kqlQueryExpression,
|
||||
notes,
|
||||
pinEvent,
|
||||
pinnedEventIds,
|
||||
range,
|
||||
removeProvider,
|
||||
show,
|
||||
sort,
|
||||
updateNote,
|
||||
unPinEvent,
|
||||
updateRange,
|
||||
updateSort,
|
||||
updateDataProviderEnabled,
|
||||
|
@ -137,6 +168,14 @@ class StatefulTimelineComponent extends React.PureComponent<Props> {
|
|||
updateItemsPerPage,
|
||||
} = this.props;
|
||||
|
||||
const onAddNoteToEvent: AddNoteToEvent = ({
|
||||
eventId,
|
||||
noteId,
|
||||
}: {
|
||||
eventId: string;
|
||||
noteId: string;
|
||||
}) => addNoteToEvent!({ id, eventId, noteId });
|
||||
|
||||
const onColumnSorted: OnColumnSorted = sorted => updateSort!({ id, sort: sorted });
|
||||
|
||||
const onDataProviderRemoved: OnDataProviderRemoved = (
|
||||
|
@ -167,34 +206,45 @@ class StatefulTimelineComponent extends React.PureComponent<Props> {
|
|||
|
||||
const onChangeDroppableAndProvider: OnChangeDroppableAndProvider = providerId =>
|
||||
updateHighlightedDropAndProviderId!({ id, providerId });
|
||||
const onPinEvent: OnPinEvent = eventId => pinEvent!({ id, eventId });
|
||||
|
||||
const onUnPinEvent: OnUnPinEvent = eventId => unPinEvent!({ id, eventId });
|
||||
|
||||
const onUpdateNote: UpdateNote = (note: Note) => updateNote!({ note });
|
||||
|
||||
return (
|
||||
<WithSource sourceId="default" indexTypes={[IndexType.ANY]}>
|
||||
{({ indexPattern }) => (
|
||||
<Timeline
|
||||
columnHeaders={headers}
|
||||
addNoteToEvent={onAddNoteToEvent}
|
||||
columnHeaders={headers!}
|
||||
columnRenderers={columnRenderers}
|
||||
id={id}
|
||||
dataProviders={dataProviders!}
|
||||
flyoutHeaderHeight={flyoutHeaderHeight}
|
||||
flyoutHeight={flyoutHeight}
|
||||
indexPattern={indexPattern}
|
||||
itemsPerPage={itemsPerPage!}
|
||||
itemsPerPageOptions={itemsPerPageOptions!}
|
||||
kqlQuery={kqlQueryExpression}
|
||||
onChangeDataProviderKqlQuery={onChangeDataProviderKqlQuery}
|
||||
onChangeDroppableAndProvider={onChangeDroppableAndProvider}
|
||||
notes={notes!}
|
||||
onChangeItemsPerPage={onChangeItemsPerPage}
|
||||
onColumnSorted={onColumnSorted}
|
||||
onDataProviderRemoved={onDataProviderRemoved}
|
||||
onFilterChange={noop} // TODO: this is the callback for column filters, which is out scope for this phase of delivery
|
||||
onPinEvent={onPinEvent}
|
||||
onUnPinEvent={onUnPinEvent}
|
||||
onRangeSelected={onRangeSelected}
|
||||
onToggleDataProviderEnabled={onToggleDataProviderEnabled}
|
||||
onToggleDataProviderExcluded={onToggleDataProviderExcluded}
|
||||
pinnedEventIds={pinnedEventIds!}
|
||||
range={range!}
|
||||
rowRenderers={rowRenderers}
|
||||
show={show!}
|
||||
sort={sort!}
|
||||
indexPattern={indexPattern}
|
||||
updateNote={onUpdateNote}
|
||||
/>
|
||||
)}
|
||||
</WithSource>
|
||||
|
@ -207,14 +257,28 @@ const makeMapStateToProps = () => {
|
|||
const getKqlQueryTimeline = timelineSelectors.getKqlFilterQuerySelector();
|
||||
const mapStateToProps = (state: State, { id }: OwnProps) => {
|
||||
const timeline: timelineModel.TimelineModel = getTimeline(state, id);
|
||||
const { dataProviders, itemsPerPage, itemsPerPageOptions, sort, show } = timeline;
|
||||
const {
|
||||
dataProviders,
|
||||
eventIdToNoteIds,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
pinnedEventIds,
|
||||
sort,
|
||||
show,
|
||||
} = timeline;
|
||||
const kqlQueryExpression = getKqlQueryTimeline(state, id);
|
||||
const notesById = appSelectors.notesByIdSelector(state);
|
||||
const notes = getEventNotes({ eventIdToNoteIds, notesById });
|
||||
|
||||
return {
|
||||
dataProviders,
|
||||
headers: defaultHeaders,
|
||||
id,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
kqlQueryExpression,
|
||||
notes,
|
||||
pinnedEventIds,
|
||||
sort,
|
||||
show,
|
||||
};
|
||||
|
@ -225,8 +289,10 @@ const makeMapStateToProps = () => {
|
|||
export const StatefulTimeline = connect(
|
||||
makeMapStateToProps,
|
||||
{
|
||||
addNoteToEvent: timelineActions.addNoteToEvent,
|
||||
addProvider: timelineActions.addProvider,
|
||||
createTimeline: timelineActions.createTimeline,
|
||||
unPinEvent: timelineActions.unPinEvent,
|
||||
updateProviders: timelineActions.updateProviders,
|
||||
updateRange: timelineActions.updateRange,
|
||||
updateSort: timelineActions.updateSort,
|
||||
|
@ -236,6 +302,8 @@ export const StatefulTimeline = connect(
|
|||
updateHighlightedDropAndProviderId: timelineActions.updateHighlightedDropAndProviderId,
|
||||
updateItemsPerPage: timelineActions.updateItemsPerPage,
|
||||
updateItemsPerPageOptions: timelineActions.updateItemsPerPageOptions,
|
||||
pinEvent: timelineActions.pinEvent,
|
||||
removeProvider: timelineActions.removeProvider,
|
||||
updateNote: appActions.updateNote,
|
||||
}
|
||||
)(StatefulTimelineComponent);
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EuiButton, EuiModal, EuiOverlayMask, EuiToolTip } from '@elastic/eui';
|
||||
import { EuiBadge, EuiButton, EuiIcon, EuiModal, EuiOverlayMask, EuiToolTip } from '@elastic/eui';
|
||||
import * as React from 'react';
|
||||
import { pure } from 'recompose';
|
||||
import uuid from 'uuid';
|
||||
|
@ -16,38 +16,27 @@ import { AssociateNote, UpdateNote } from '../../notes/helpers';
|
|||
import {
|
||||
ButtonContainer,
|
||||
DescriptionField,
|
||||
EmptyStar,
|
||||
Facet,
|
||||
HistoryButtonLabel,
|
||||
LabelText,
|
||||
NameField,
|
||||
NotesButtonLabel,
|
||||
StarSvg,
|
||||
NotesIconContainer,
|
||||
PositionedNotesIcon,
|
||||
SmallNotesButtonContainer,
|
||||
StyledStar,
|
||||
} from './styles';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export const historyToolTip = 'The chronological history of actions related to this timeline';
|
||||
export const streamLiveToolTip = 'Update the Timeline as new data arrives';
|
||||
export const newTimelineToolTip = 'Create a new timeline';
|
||||
|
||||
type CreateTimeline = ({ id, show }: { id: string; show?: boolean }) => void;
|
||||
type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void;
|
||||
type UpdateIsLive = ({ id, isLive }: { id: string; isLive: boolean }) => void;
|
||||
type UpdateTitle = ({ id, title }: { id: string; title: string }) => void;
|
||||
type UpdateDescription = ({ id, description }: { id: string; description: string }) => void;
|
||||
|
||||
// TODO: replace this svg with the same filled EuiIcon
|
||||
const FilledStar = pure<{ starFill: string; starStroke: string }>(({ starFill, starStroke }) => (
|
||||
<StarSvg
|
||||
data-test-subj="timeline-star-svg"
|
||||
viewBox="0 35 120 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<polygon
|
||||
fillRule="nonzero"
|
||||
fill={starFill}
|
||||
stroke={starStroke}
|
||||
points="50,0 21,90 98,35 2,35 79,90"
|
||||
/>
|
||||
</StarSvg>
|
||||
));
|
||||
|
||||
export const StarIcon = pure<{
|
||||
isFavorite: boolean;
|
||||
timelineId: string;
|
||||
|
@ -56,15 +45,11 @@ export const StarIcon = pure<{
|
|||
<div role="button" onClick={() => updateIsFavorite({ id, isFavorite: !isFavorite })}>
|
||||
{isFavorite ? (
|
||||
<EuiToolTip data-test-subj="timeline-favorite-filled-star-tool-tip" content={i18n.FAVORITE}>
|
||||
<FilledStar
|
||||
data-test-subj="timeline-favorite-filled-star"
|
||||
starStroke="#E6C220"
|
||||
starFill="#E6C220"
|
||||
/>
|
||||
<StyledStar data-test-subj="timeline-favorite-filled-star" type="starFilled" size="l" />
|
||||
</EuiToolTip>
|
||||
) : (
|
||||
<EuiToolTip content={i18n.NOT_A_FAVORITE}>
|
||||
<EmptyStar data-test-subj="timeline-favorite-empty-star" type="starEmpty" size="l" />
|
||||
<StyledStar data-test-subj="timeline-favorite-empty-star" type="starEmpty" size="l" />
|
||||
</EuiToolTip>
|
||||
)}
|
||||
</div>
|
||||
|
@ -122,49 +107,136 @@ export const NewTimeline = pure<{
|
|||
));
|
||||
|
||||
interface NotesButtonProps {
|
||||
animate?: boolean;
|
||||
associateNote: AssociateNote;
|
||||
notes: Note[];
|
||||
size: 's' | 'l';
|
||||
showNotes: boolean;
|
||||
toggleShowNotes: () => void;
|
||||
text?: string;
|
||||
toolTip?: string;
|
||||
updateNote: UpdateNote;
|
||||
}
|
||||
|
||||
const getNewNoteId = (): string => uuid.v4();
|
||||
|
||||
export const NotesButton = pure<NotesButtonProps>(
|
||||
({ associateNote, notes, showNotes, toggleShowNotes, updateNote }) => (
|
||||
<ButtonContainer>
|
||||
<EuiToolTip data-test-subj="timeline-notes-tool-tip" content={i18n.NOTES_TOOL_TIP}>
|
||||
<>
|
||||
<EuiButton
|
||||
data-test-subj="timeline-notes"
|
||||
iconType="arrowDown"
|
||||
iconSide="right"
|
||||
onClick={() => toggleShowNotes()}
|
||||
>
|
||||
<NotesButtonLabel>
|
||||
<Facet>{notes.length}</Facet>
|
||||
<LabelText>{i18n.NOTES}</LabelText>
|
||||
</NotesButtonLabel>
|
||||
</EuiButton>
|
||||
{showNotes ? (
|
||||
<EuiOverlayMask>
|
||||
<EuiModal onClose={toggleShowNotes}>
|
||||
<Notes
|
||||
associateNote={associateNote}
|
||||
notes={notes}
|
||||
getNewNoteId={getNewNoteId}
|
||||
updateNote={updateNote}
|
||||
/>
|
||||
</EuiModal>
|
||||
</EuiOverlayMask>
|
||||
) : null}
|
||||
</>
|
||||
</EuiToolTip>
|
||||
const NotesIcon = pure<{ notes: Note[]; size: 's' | 'l' }>(({ notes, size }) => (
|
||||
<>
|
||||
<EuiBadge data-test-subj="timeline-notes-count" color="hollow">
|
||||
{notes.length}
|
||||
</EuiBadge>
|
||||
<NotesIconContainer>
|
||||
<PositionedNotesIcon size={size}>
|
||||
<EuiIcon data-test-subj="timeline-notes-icon" size="m" type="pencil" />
|
||||
</PositionedNotesIcon>
|
||||
</NotesIconContainer>
|
||||
</>
|
||||
));
|
||||
|
||||
const LargeNotesButton = pure<{ notes: Note[]; text?: string; toggleShowNotes: () => void }>(
|
||||
({ notes, text, toggleShowNotes }) => (
|
||||
<EuiButton
|
||||
data-test-subj="timeline-notes-button-large"
|
||||
onClick={() => toggleShowNotes()}
|
||||
size="l"
|
||||
>
|
||||
<NotesButtonLabel>
|
||||
<NotesIcon notes={notes} size="l" />
|
||||
{text && text.length ? <LabelText>{text}</LabelText> : null}
|
||||
</NotesButtonLabel>
|
||||
</EuiButton>
|
||||
)
|
||||
);
|
||||
|
||||
const SmallNotesButton = pure<{ notes: Note[]; toggleShowNotes: () => void }>(
|
||||
({ notes, toggleShowNotes }) => (
|
||||
<SmallNotesButtonContainer
|
||||
data-test-subj="timeline-notes-button-small"
|
||||
onClick={() => toggleShowNotes()}
|
||||
role="button"
|
||||
>
|
||||
<NotesIcon notes={notes} size="s" />
|
||||
</SmallNotesButtonContainer>
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* The internal implementation of the `NotesButton`
|
||||
*/
|
||||
const NotesButtonComponent = pure<NotesButtonProps>(
|
||||
({
|
||||
animate = true,
|
||||
associateNote,
|
||||
notes,
|
||||
showNotes,
|
||||
size,
|
||||
toggleShowNotes,
|
||||
text,
|
||||
updateNote,
|
||||
}) => (
|
||||
<ButtonContainer animate={animate} data-test-subj="timeline-notes-button-container">
|
||||
<>
|
||||
{size === 'l' ? (
|
||||
<LargeNotesButton notes={notes} text={text} toggleShowNotes={toggleShowNotes} />
|
||||
) : (
|
||||
<SmallNotesButton notes={notes} toggleShowNotes={toggleShowNotes} />
|
||||
)}
|
||||
{showNotes ? (
|
||||
<EuiOverlayMask>
|
||||
<EuiModal onClose={toggleShowNotes}>
|
||||
<Notes
|
||||
associateNote={associateNote}
|
||||
notes={notes}
|
||||
getNewNoteId={getNewNoteId}
|
||||
updateNote={updateNote}
|
||||
/>
|
||||
</EuiModal>
|
||||
</EuiOverlayMask>
|
||||
) : null}
|
||||
</>
|
||||
</ButtonContainer>
|
||||
)
|
||||
);
|
||||
|
||||
export const NotesButton = pure<NotesButtonProps>(
|
||||
({
|
||||
animate = true,
|
||||
associateNote,
|
||||
notes,
|
||||
showNotes,
|
||||
size,
|
||||
toggleShowNotes,
|
||||
toolTip,
|
||||
text,
|
||||
updateNote,
|
||||
}) =>
|
||||
showNotes ? (
|
||||
<NotesButtonComponent
|
||||
animate={animate}
|
||||
associateNote={associateNote}
|
||||
notes={notes}
|
||||
showNotes={showNotes}
|
||||
size={size}
|
||||
toggleShowNotes={toggleShowNotes}
|
||||
text={text}
|
||||
updateNote={updateNote}
|
||||
/>
|
||||
) : (
|
||||
<EuiToolTip content={toolTip || ''} data-test-subj="timeline-notes-tool-tip">
|
||||
<NotesButtonComponent
|
||||
animate={animate}
|
||||
associateNote={associateNote}
|
||||
notes={notes}
|
||||
showNotes={showNotes}
|
||||
size={size}
|
||||
toggleShowNotes={toggleShowNotes}
|
||||
text={text}
|
||||
updateNote={updateNote}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
)
|
||||
);
|
||||
|
||||
export const HistoryButton = pure<{ history: History[] }>(({ history }) => (
|
||||
<EuiToolTip data-test-subj="timeline-history-tool-tip" content={i18n.HISTORY_TOOL_TIP}>
|
||||
<EuiButton
|
||||
|
@ -173,9 +245,12 @@ export const HistoryButton = pure<{ history: History[] }>(({ history }) => (
|
|||
iconSide="right"
|
||||
isDisabled={true}
|
||||
onClick={() => window.alert('Show history')}
|
||||
size="l"
|
||||
>
|
||||
<HistoryButtonLabel>
|
||||
<Facet>{history.length}</Facet>
|
||||
<EuiBadge data-test-subj="history-count" color="hollow">
|
||||
{history.length}
|
||||
</EuiBadge>
|
||||
<LabelText>{i18n.HISTORY}</LabelText>
|
||||
</HistoryButtonLabel>
|
||||
</EuiButton>
|
||||
|
@ -185,7 +260,7 @@ export const HistoryButton = pure<{ history: History[] }>(({ history }) => (
|
|||
export const StreamLive = pure<{ isLive: boolean; timelineId: string; updateIsLive: UpdateIsLive }>(
|
||||
({ isLive, timelineId, updateIsLive }) => (
|
||||
<EuiToolTip data-test-subj="timeline-stream-tool-tip" content={i18n.STREAM_LIVE_TOOL_TIP}>
|
||||
<ButtonContainer>
|
||||
<ButtonContainer animate={true}>
|
||||
<EuiButton
|
||||
data-test-subj="timeline-stream-live"
|
||||
color={isLive ? 'secondary' : 'primary'}
|
||||
|
@ -194,6 +269,7 @@ export const StreamLive = pure<{ isLive: boolean; timelineId: string; updateIsLi
|
|||
iconSide="left"
|
||||
isDisabled={true}
|
||||
onClick={() => updateIsLive({ id: timelineId, isLive: !isLive })}
|
||||
size="l"
|
||||
>
|
||||
{i18n.STREAM_LIVE}
|
||||
</EuiButton>
|
||||
|
|
|
@ -20,7 +20,7 @@ import {
|
|||
StarIcon,
|
||||
StreamLive,
|
||||
} from './helpers';
|
||||
import { NotesHistoryActions, TimelineProperties } from './styles';
|
||||
import { PropertiesLeft, PropertiesRight, TimelineProperties } from './styles';
|
||||
import * as i18n from './translations';
|
||||
|
||||
type CreateTimeline = ({ id, show }: { id: string; show?: boolean }) => void;
|
||||
|
@ -117,30 +117,36 @@ export class Properties extends React.PureComponent<Props, State> {
|
|||
|
||||
return (
|
||||
<TimelineProperties data-test-subj="timeline-properties">
|
||||
<StarIcon
|
||||
isFavorite={isFavorite}
|
||||
timelineId={timelineId}
|
||||
updateIsFavorite={updateIsFavorite}
|
||||
/>
|
||||
|
||||
<Name timelineId={timelineId} title={title} updateTitle={updateTitle} />
|
||||
|
||||
{width >= showDescriptionThreshold ? (
|
||||
<Description
|
||||
description={description}
|
||||
<PropertiesLeft>
|
||||
<StarIcon
|
||||
isFavorite={isFavorite}
|
||||
timelineId={timelineId}
|
||||
updateDescription={updateDescription}
|
||||
updateIsFavorite={updateIsFavorite}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<NotesHistoryActions gutterSize="s" alignItems="center">
|
||||
<Name timelineId={timelineId} title={title} updateTitle={updateTitle} />
|
||||
|
||||
{width >= showDescriptionThreshold ? (
|
||||
<Description
|
||||
description={description}
|
||||
timelineId={timelineId}
|
||||
updateDescription={updateDescription}
|
||||
/>
|
||||
) : null}
|
||||
</PropertiesLeft>
|
||||
|
||||
<PropertiesRight gutterSize="s" alignItems="center">
|
||||
{width >= showNotesThreshold ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<NotesButton
|
||||
animate={true}
|
||||
associateNote={associateNote}
|
||||
notes={notes}
|
||||
showNotes={this.state.showNotes}
|
||||
size="l"
|
||||
text={i18n.NOTES}
|
||||
toggleShowNotes={this.onToggleShowNotes.bind(this)}
|
||||
toolTip={i18n.NOTES_TOOL_TIP}
|
||||
updateNote={updateNote}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
@ -188,10 +194,14 @@ export class Properties extends React.PureComponent<Props, State> {
|
|||
{width < showNotesThreshold ? (
|
||||
<EuiFormRow>
|
||||
<NotesButton
|
||||
animate={true}
|
||||
associateNote={associateNote}
|
||||
notes={notes}
|
||||
showNotes={this.state.showNotes}
|
||||
size="l"
|
||||
text={i18n.NOTES}
|
||||
toggleShowNotes={this.onToggleShowNotes.bind(this)}
|
||||
toolTip={i18n.NOTES_TOOL_TIP}
|
||||
updateNote={updateNote}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
@ -215,7 +225,7 @@ export class Properties extends React.PureComponent<Props, State> {
|
|||
</EuiForm>
|
||||
</EuiPopover>
|
||||
</EuiFlexItem>
|
||||
</NotesHistoryActions>
|
||||
</PropertiesRight>
|
||||
</TimelineProperties>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -16,7 +16,9 @@ export const TimelineProperties = styled.div`
|
|||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
user-select: none;
|
||||
min-width: 100%;
|
||||
`;
|
||||
|
||||
export const NameField = styled(EuiFieldText)`
|
||||
|
@ -33,11 +35,28 @@ export const DescriptionField = styled(EuiFieldText)`
|
|||
|
||||
export const NotesButtonLabel = styled.div`
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
export const ButtonContainer = styled.div`
|
||||
animation: ${fadeInEffect} 0.3s;
|
||||
export const NotesIconContainer = styled.div`
|
||||
position: relative;
|
||||
margin-left: 5px;
|
||||
`;
|
||||
|
||||
export const PositionedNotesIcon = styled.div<{ size: 'l' | 's' }>`
|
||||
left: ${({ size }) => (size === 'l' ? '-12px' : '13px')};
|
||||
overflow: visible;
|
||||
position: absolute;
|
||||
top: ${({ size }) => (size === 'l' ? '-22px' : '-19px')};
|
||||
`;
|
||||
|
||||
export const SmallNotesButtonContainer = styled.div`
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export const ButtonContainer = styled.div<{ animate: boolean }>`
|
||||
animation: ${fadeInEffect} ${({ animate }) => (animate ? '0.3s' : '0s')};
|
||||
`;
|
||||
|
||||
export const HistoryButtonLabel = styled.div`
|
||||
|
@ -46,25 +65,21 @@ export const HistoryButtonLabel = styled.div`
|
|||
`;
|
||||
|
||||
export const LabelText = styled.div`
|
||||
margin-left: 5px;
|
||||
margin-left: 10px;
|
||||
`;
|
||||
|
||||
export const StarSvg = styled.svg`
|
||||
& > polygon:hover {
|
||||
fill: #f98510;
|
||||
}
|
||||
animation: ${fadeInEffect} 0.3s;
|
||||
height: 35px;
|
||||
cursor: pointer;
|
||||
width: 35px;
|
||||
`;
|
||||
|
||||
export const EmptyStar = styled(EuiIcon)`
|
||||
export const StyledStar = styled(EuiIcon)`
|
||||
margin-right: 10px;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export const NotesHistoryActions = styled(EuiFlexGroup)`
|
||||
export const PropertiesLeft = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
export const PropertiesRight = styled(EuiFlexGroup)`
|
||||
margin-left: 15px;
|
||||
`;
|
||||
|
||||
|
@ -81,4 +96,5 @@ export const Facet = styled.div`
|
|||
min-width: 20px;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
user-select: none;
|
||||
`;
|
||||
|
|
|
@ -43,7 +43,7 @@ export const DESCRIPTION = i18n.translate('xpack.secops.timeline.properties.desc
|
|||
export const DESCRIPTION_TOOL_TIP = i18n.translate(
|
||||
'xpack.secops.timeline.properties.descriptionToolTip',
|
||||
{
|
||||
defaultMessage: 'The story told by the events and notes in this Timeline',
|
||||
defaultMessage: 'A summary of the events and notes in this Timeline',
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -12,10 +12,17 @@ import {
|
|||
EuiSuperSelect,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { KqlMode } from '../../../store/local/timeline/model';
|
||||
import { AndOrBadge } from '../../and_or_badge';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const AndOrContainer = styled.div`
|
||||
position: relative;
|
||||
top: -1px;
|
||||
`;
|
||||
|
||||
interface ModeProperties {
|
||||
mode: KqlMode;
|
||||
description: string;
|
||||
|
@ -44,9 +51,15 @@ export const modes: { [key in KqlMode]: ModeProperties } = {
|
|||
export const options = [
|
||||
{
|
||||
value: modes.filter.mode,
|
||||
inputDisplay: modes.filter.selectText,
|
||||
inputDisplay: (
|
||||
<AndOrContainer>
|
||||
<AndOrBadge type="and" />
|
||||
{modes.filter.selectText}
|
||||
</AndOrContainer>
|
||||
),
|
||||
dropdownDisplay: (
|
||||
<>
|
||||
<AndOrBadge type="and" />
|
||||
<strong>{modes.filter.selectText}</strong>
|
||||
<EuiSpacer size="xs" />
|
||||
<EuiText size="s" color="subdued">
|
||||
|
@ -57,9 +70,15 @@ export const options = [
|
|||
},
|
||||
{
|
||||
value: modes.search.mode,
|
||||
inputDisplay: modes.search.selectText,
|
||||
inputDisplay: (
|
||||
<AndOrContainer>
|
||||
<AndOrBadge type="or" />
|
||||
{modes.search.selectText}
|
||||
</AndOrContainer>
|
||||
),
|
||||
dropdownDisplay: (
|
||||
<>
|
||||
<AndOrBadge type="or" />
|
||||
<strong>{modes.search.selectText}</strong>
|
||||
<EuiSpacer size="xs" />
|
||||
<EuiText size="s" color="subdued">
|
||||
|
|
|
@ -54,7 +54,7 @@ interface Props {
|
|||
setKqlFilterQueryDraft: (expression: string) => void;
|
||||
}
|
||||
|
||||
const SearchAndFilterContainer = styled.div`
|
||||
const SearchOrFilterContainer = styled.div`
|
||||
margin: 5px 0 10px 0;
|
||||
user-select: none;
|
||||
`;
|
||||
|
@ -63,8 +63,6 @@ const ModeFlexItem = styled(EuiFlexItem)`
|
|||
user-select: none;
|
||||
`;
|
||||
|
||||
const SuperSelect = styled(EuiSuperSelect)``;
|
||||
|
||||
export const SearchOrFilter = pure<Props>(
|
||||
({
|
||||
applyKqlFilterQuery,
|
||||
|
@ -76,11 +74,11 @@ export const SearchOrFilter = pure<Props>(
|
|||
setKqlFilterQueryDraft,
|
||||
updateKqlMode,
|
||||
}) => (
|
||||
<SearchAndFilterContainer>
|
||||
<EuiFlexGroup data-test-subj="timeline-search-and-filter-container">
|
||||
<SearchOrFilterContainer>
|
||||
<EuiFlexGroup data-test-subj="timeline-search-or-filter" gutterSize="xs">
|
||||
<ModeFlexItem grow={false}>
|
||||
<EuiToolTip content={i18n.FILTER_OR_SEARCH_WITH_KQL}>
|
||||
<SuperSelect
|
||||
<EuiSuperSelect
|
||||
data-test-subj="timeline-select-search-or-filter"
|
||||
hasDividers={true}
|
||||
itemLayoutAlign="top"
|
||||
|
@ -91,7 +89,7 @@ export const SearchOrFilter = pure<Props>(
|
|||
/>
|
||||
</EuiToolTip>
|
||||
</ModeFlexItem>
|
||||
<EuiFlexItem data-test-subj="timeline-search-container">
|
||||
<EuiFlexItem data-test-subj="timeline-search-or-filter-search-container">
|
||||
<EuiToolTip content={modes[kqlMode].kqlBarTooltip}>
|
||||
<>
|
||||
{kqlMode === 'filter' && (
|
||||
|
@ -134,6 +132,6 @@ export const SearchOrFilter = pure<Props>(
|
|||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</SearchAndFilterContainer>
|
||||
</SearchOrFilterContainer>
|
||||
)
|
||||
);
|
||||
|
|
|
@ -21,7 +21,7 @@ import { mockGlobalState, mockIndexPattern } from '../../mock';
|
|||
import { createStore, State } from '../../store';
|
||||
import { flyoutHeaderHeight } from '../flyout';
|
||||
import { ColumnHeaderType } from './body/column_headers/column_header';
|
||||
import { headers } from './body/column_headers/headers';
|
||||
import { defaultHeaders } from './body/column_headers/headers';
|
||||
import { columnRenderers, rowRenderers } from './body/renderers';
|
||||
import { Sort } from './body/sort';
|
||||
import { mockDataProviders } from './data_providers/mock/mock_data_providers';
|
||||
|
@ -65,29 +65,35 @@ describe('Timeline', () => {
|
|||
<DragDropContext onDragEnd={noop}>
|
||||
<MockedProvider mocks={mocks}>
|
||||
<Timeline
|
||||
addNoteToEvent={noop}
|
||||
id="foo"
|
||||
columnHeaders={headers}
|
||||
columnHeaders={defaultHeaders}
|
||||
columnRenderers={columnRenderers}
|
||||
dataProviders={mockDataProviders}
|
||||
flyoutHeight={testFlyoutHeight}
|
||||
flyoutHeaderHeight={flyoutHeaderHeight}
|
||||
indexPattern={indexPattern}
|
||||
itemsPerPage={5}
|
||||
itemsPerPageOptions={[5, 10, 20]}
|
||||
kqlQuery=""
|
||||
notes={{}}
|
||||
onChangeDataProviderKqlQuery={noop}
|
||||
onChangeDroppableAndProvider={noop}
|
||||
onChangeItemsPerPage={noop}
|
||||
onColumnSorted={noop}
|
||||
onDataProviderRemoved={noop}
|
||||
onFilterChange={noop}
|
||||
onPinEvent={noop}
|
||||
onRangeSelected={noop}
|
||||
onToggleDataProviderEnabled={noop}
|
||||
onToggleDataProviderExcluded={noop}
|
||||
onUnPinEvent={noop}
|
||||
pinnedEventIds={{}}
|
||||
range={'1 Day'}
|
||||
rowRenderers={rowRenderers}
|
||||
show={true}
|
||||
sort={sort}
|
||||
indexPattern={indexPattern}
|
||||
updateNote={noop}
|
||||
/>
|
||||
</MockedProvider>
|
||||
</DragDropContext>
|
||||
|
@ -107,29 +113,35 @@ describe('Timeline', () => {
|
|||
<DragDropContext onDragEnd={noop}>
|
||||
<MockedProvider mocks={mocks}>
|
||||
<Timeline
|
||||
addNoteToEvent={noop}
|
||||
id="foo"
|
||||
columnHeaders={headers}
|
||||
columnHeaders={defaultHeaders}
|
||||
columnRenderers={columnRenderers}
|
||||
dataProviders={mockDataProviders}
|
||||
flyoutHeight={testFlyoutHeight}
|
||||
flyoutHeaderHeight={flyoutHeaderHeight}
|
||||
indexPattern={indexPattern}
|
||||
itemsPerPage={5}
|
||||
itemsPerPageOptions={[5, 10, 20]}
|
||||
kqlQuery=""
|
||||
notes={{}}
|
||||
onChangeDataProviderKqlQuery={noop}
|
||||
onChangeDroppableAndProvider={noop}
|
||||
onChangeItemsPerPage={noop}
|
||||
onColumnSorted={noop}
|
||||
onDataProviderRemoved={noop}
|
||||
onFilterChange={noop}
|
||||
onPinEvent={noop}
|
||||
onRangeSelected={noop}
|
||||
onToggleDataProviderEnabled={noop}
|
||||
onToggleDataProviderExcluded={noop}
|
||||
onUnPinEvent={noop}
|
||||
pinnedEventIds={{}}
|
||||
range={'1 Day'}
|
||||
rowRenderers={rowRenderers}
|
||||
show={true}
|
||||
sort={sort}
|
||||
indexPattern={indexPattern}
|
||||
updateNote={noop}
|
||||
/>
|
||||
</MockedProvider>
|
||||
</DragDropContext>
|
||||
|
@ -138,7 +150,7 @@ describe('Timeline', () => {
|
|||
</I18nProvider>
|
||||
);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="scrollableArea"]').exists()).toEqual(true);
|
||||
expect(wrapper.find('[data-test-subj="horizontal-scroll"]').exists()).toEqual(true);
|
||||
});
|
||||
|
||||
test('it does NOT render the paging footer when you do NOT have any data providers', () => {
|
||||
|
@ -149,29 +161,35 @@ describe('Timeline', () => {
|
|||
<DragDropContext onDragEnd={noop}>
|
||||
<MockedProvider mocks={mocks}>
|
||||
<Timeline
|
||||
addNoteToEvent={noop}
|
||||
id="foo"
|
||||
columnHeaders={headers}
|
||||
columnHeaders={defaultHeaders}
|
||||
columnRenderers={columnRenderers}
|
||||
dataProviders={[]}
|
||||
flyoutHeight={testFlyoutHeight}
|
||||
flyoutHeaderHeight={flyoutHeaderHeight}
|
||||
indexPattern={indexPattern}
|
||||
itemsPerPage={5}
|
||||
itemsPerPageOptions={[5, 10, 20]}
|
||||
kqlQuery=""
|
||||
notes={{}}
|
||||
onChangeDataProviderKqlQuery={noop}
|
||||
onChangeDroppableAndProvider={noop}
|
||||
onChangeItemsPerPage={noop}
|
||||
onColumnSorted={noop}
|
||||
onDataProviderRemoved={noop}
|
||||
onFilterChange={noop}
|
||||
onPinEvent={noop}
|
||||
onRangeSelected={noop}
|
||||
onToggleDataProviderEnabled={noop}
|
||||
onToggleDataProviderExcluded={noop}
|
||||
onUnPinEvent={noop}
|
||||
pinnedEventIds={{}}
|
||||
range={'1 Day'}
|
||||
rowRenderers={rowRenderers}
|
||||
show={true}
|
||||
sort={sort}
|
||||
indexPattern={indexPattern}
|
||||
updateNote={noop}
|
||||
/>
|
||||
</MockedProvider>
|
||||
</DragDropContext>
|
||||
|
@ -196,29 +214,35 @@ describe('Timeline', () => {
|
|||
<DragDropContext onDragEnd={noop}>
|
||||
<MockedProvider mocks={mocks}>
|
||||
<Timeline
|
||||
addNoteToEvent={noop}
|
||||
id="foo"
|
||||
columnHeaders={headers}
|
||||
columnHeaders={defaultHeaders}
|
||||
columnRenderers={columnRenderers}
|
||||
dataProviders={mockDataProviders}
|
||||
flyoutHeight={testFlyoutHeight}
|
||||
flyoutHeaderHeight={flyoutHeaderHeight}
|
||||
indexPattern={indexPattern}
|
||||
itemsPerPage={5}
|
||||
itemsPerPageOptions={[5, 10, 20]}
|
||||
kqlQuery=""
|
||||
notes={{}}
|
||||
onChangeDataProviderKqlQuery={noop}
|
||||
onChangeDroppableAndProvider={noop}
|
||||
onColumnSorted={mockOnColumnSorted}
|
||||
onChangeItemsPerPage={noop}
|
||||
onDataProviderRemoved={noop}
|
||||
onFilterChange={noop}
|
||||
onPinEvent={noop}
|
||||
onRangeSelected={noop}
|
||||
onToggleDataProviderEnabled={noop}
|
||||
onToggleDataProviderExcluded={noop}
|
||||
onUnPinEvent={noop}
|
||||
pinnedEventIds={{}}
|
||||
range={'1 Day'}
|
||||
rowRenderers={rowRenderers}
|
||||
show={true}
|
||||
sort={sort}
|
||||
indexPattern={indexPattern}
|
||||
updateNote={noop}
|
||||
/>
|
||||
</MockedProvider>
|
||||
</DragDropContext>
|
||||
|
@ -233,7 +257,7 @@ describe('Timeline', () => {
|
|||
.simulate('click');
|
||||
|
||||
expect(mockOnColumnSorted).toBeCalledWith({
|
||||
columnId: headers[0].id,
|
||||
columnId: defaultHeaders[0].id,
|
||||
sortDirection: 'ascending',
|
||||
});
|
||||
});
|
||||
|
@ -250,29 +274,35 @@ describe('Timeline', () => {
|
|||
<DragDropContext onDragEnd={noop}>
|
||||
<MockedProvider mocks={mocks}>
|
||||
<Timeline
|
||||
addNoteToEvent={noop}
|
||||
id="foo"
|
||||
columnHeaders={headers}
|
||||
columnHeaders={defaultHeaders}
|
||||
columnRenderers={columnRenderers}
|
||||
dataProviders={mockDataProviders}
|
||||
flyoutHeight={testFlyoutHeight}
|
||||
flyoutHeaderHeight={flyoutHeaderHeight}
|
||||
indexPattern={indexPattern}
|
||||
itemsPerPage={5}
|
||||
itemsPerPageOptions={[5, 10, 20]}
|
||||
kqlQuery=""
|
||||
notes={{}}
|
||||
onChangeDataProviderKqlQuery={noop}
|
||||
onChangeDroppableAndProvider={noop}
|
||||
onChangeItemsPerPage={noop}
|
||||
onColumnSorted={noop}
|
||||
onDataProviderRemoved={mockOnDataProviderRemoved}
|
||||
onFilterChange={noop}
|
||||
onPinEvent={noop}
|
||||
onRangeSelected={noop}
|
||||
onToggleDataProviderEnabled={noop}
|
||||
onToggleDataProviderExcluded={noop}
|
||||
onUnPinEvent={noop}
|
||||
pinnedEventIds={{}}
|
||||
range={'1 Day'}
|
||||
rowRenderers={rowRenderers}
|
||||
show={true}
|
||||
sort={sort}
|
||||
indexPattern={indexPattern}
|
||||
updateNote={noop}
|
||||
/>
|
||||
</MockedProvider>
|
||||
</DragDropContext>
|
||||
|
@ -299,29 +329,35 @@ describe('Timeline', () => {
|
|||
<DragDropContext onDragEnd={noop}>
|
||||
<MockedProvider mocks={mocks}>
|
||||
<Timeline
|
||||
addNoteToEvent={noop}
|
||||
id="foo"
|
||||
columnHeaders={headers}
|
||||
columnHeaders={defaultHeaders}
|
||||
columnRenderers={columnRenderers}
|
||||
dataProviders={mockDataProviders}
|
||||
flyoutHeight={testFlyoutHeight}
|
||||
flyoutHeaderHeight={flyoutHeaderHeight}
|
||||
indexPattern={indexPattern}
|
||||
itemsPerPage={5}
|
||||
itemsPerPageOptions={[5, 10, 20]}
|
||||
kqlQuery=""
|
||||
notes={{}}
|
||||
onChangeDataProviderKqlQuery={noop}
|
||||
onChangeDroppableAndProvider={noop}
|
||||
onChangeItemsPerPage={noop}
|
||||
onColumnSorted={noop}
|
||||
onDataProviderRemoved={mockOnDataProviderRemoved}
|
||||
onFilterChange={noop}
|
||||
onPinEvent={noop}
|
||||
onRangeSelected={noop}
|
||||
onToggleDataProviderEnabled={noop}
|
||||
onToggleDataProviderExcluded={noop}
|
||||
onUnPinEvent={noop}
|
||||
pinnedEventIds={{}}
|
||||
range={'1 Day'}
|
||||
rowRenderers={rowRenderers}
|
||||
show={true}
|
||||
sort={sort}
|
||||
indexPattern={indexPattern}
|
||||
updateNote={noop}
|
||||
/>
|
||||
</MockedProvider>
|
||||
</DragDropContext>
|
||||
|
@ -351,7 +387,7 @@ describe('Timeline', () => {
|
|||
const mockOnFilterChange = jest.fn();
|
||||
|
||||
// for this test, all columns have text filters
|
||||
const allColumnsHaveTextFilters = headers.map(header => ({
|
||||
const allColumnsHaveTextFilters = defaultHeaders.map(header => ({
|
||||
...header,
|
||||
columnHeaderType: 'text-filter' as ColumnHeaderType,
|
||||
}));
|
||||
|
@ -363,29 +399,35 @@ describe('Timeline', () => {
|
|||
<DragDropContext onDragEnd={noop}>
|
||||
<MockedProvider mocks={mocks}>
|
||||
<Timeline
|
||||
addNoteToEvent={noop}
|
||||
id="foo"
|
||||
columnHeaders={allColumnsHaveTextFilters}
|
||||
columnRenderers={columnRenderers}
|
||||
dataProviders={mockDataProviders}
|
||||
flyoutHeight={testFlyoutHeight}
|
||||
flyoutHeaderHeight={flyoutHeaderHeight}
|
||||
indexPattern={indexPattern}
|
||||
itemsPerPage={5}
|
||||
itemsPerPageOptions={[5, 10, 20]}
|
||||
kqlQuery=""
|
||||
notes={{}}
|
||||
onChangeDataProviderKqlQuery={noop}
|
||||
onChangeDroppableAndProvider={noop}
|
||||
onChangeItemsPerPage={noop}
|
||||
onColumnSorted={noop}
|
||||
onDataProviderRemoved={noop}
|
||||
onFilterChange={mockOnFilterChange}
|
||||
onPinEvent={noop}
|
||||
onRangeSelected={noop}
|
||||
onToggleDataProviderEnabled={noop}
|
||||
onToggleDataProviderExcluded={noop}
|
||||
onUnPinEvent={noop}
|
||||
pinnedEventIds={{}}
|
||||
range={'1 Day'}
|
||||
rowRenderers={rowRenderers}
|
||||
show={true}
|
||||
sort={sort}
|
||||
indexPattern={indexPattern}
|
||||
updateNote={noop}
|
||||
/>
|
||||
</MockedProvider>
|
||||
</DragDropContext>
|
||||
|
@ -400,7 +442,7 @@ describe('Timeline', () => {
|
|||
.simulate('change', { target: { value: newFilter } });
|
||||
|
||||
expect(mockOnFilterChange).toBeCalledWith({
|
||||
columnId: headers[0].id,
|
||||
columnId: defaultHeaders[0].id,
|
||||
filter: newFilter,
|
||||
});
|
||||
});
|
||||
|
@ -411,7 +453,7 @@ describe('Timeline', () => {
|
|||
const mockOnToggleDataProviderEnabled = jest.fn();
|
||||
|
||||
// for this test, all columns have text filters
|
||||
const allColumnsHaveTextFilters = headers.map(header => ({
|
||||
const allColumnsHaveTextFilters = defaultHeaders.map(header => ({
|
||||
...header,
|
||||
columnHeaderType: 'text-filter' as ColumnHeaderType,
|
||||
}));
|
||||
|
@ -423,29 +465,35 @@ describe('Timeline', () => {
|
|||
<DragDropContext onDragEnd={noop}>
|
||||
<MockedProvider mocks={mocks}>
|
||||
<Timeline
|
||||
addNoteToEvent={noop}
|
||||
id="foo"
|
||||
columnHeaders={allColumnsHaveTextFilters}
|
||||
columnRenderers={columnRenderers}
|
||||
dataProviders={mockDataProviders}
|
||||
flyoutHeight={testFlyoutHeight}
|
||||
flyoutHeaderHeight={flyoutHeaderHeight}
|
||||
indexPattern={indexPattern}
|
||||
itemsPerPage={5}
|
||||
itemsPerPageOptions={[5, 10, 20]}
|
||||
kqlQuery=""
|
||||
notes={{}}
|
||||
onChangeDataProviderKqlQuery={noop}
|
||||
onChangeDroppableAndProvider={noop}
|
||||
onChangeItemsPerPage={noop}
|
||||
onColumnSorted={noop}
|
||||
onDataProviderRemoved={noop}
|
||||
onFilterChange={noop}
|
||||
onPinEvent={noop}
|
||||
onRangeSelected={noop}
|
||||
onToggleDataProviderEnabled={mockOnToggleDataProviderEnabled}
|
||||
onToggleDataProviderExcluded={noop}
|
||||
onUnPinEvent={noop}
|
||||
pinnedEventIds={{}}
|
||||
range={'1 Day'}
|
||||
rowRenderers={rowRenderers}
|
||||
show={true}
|
||||
sort={sort}
|
||||
indexPattern={indexPattern}
|
||||
updateNote={noop}
|
||||
/>
|
||||
</MockedProvider>
|
||||
</DragDropContext>
|
||||
|
@ -478,7 +526,7 @@ describe('Timeline', () => {
|
|||
const mockOnToggleDataProviderExcluded = jest.fn();
|
||||
|
||||
// for this test, all columns have text filters
|
||||
const allColumnsHaveTextFilters = headers.map(header => ({
|
||||
const allColumnsHaveTextFilters = defaultHeaders.map(header => ({
|
||||
...header,
|
||||
columnHeaderType: 'text-filter' as ColumnHeaderType,
|
||||
}));
|
||||
|
@ -490,29 +538,35 @@ describe('Timeline', () => {
|
|||
<DragDropContext onDragEnd={noop}>
|
||||
<MockedProvider mocks={mocks}>
|
||||
<Timeline
|
||||
addNoteToEvent={noop}
|
||||
id="foo"
|
||||
columnHeaders={allColumnsHaveTextFilters}
|
||||
columnRenderers={columnRenderers}
|
||||
dataProviders={mockDataProviders}
|
||||
flyoutHeight={testFlyoutHeight}
|
||||
flyoutHeaderHeight={flyoutHeaderHeight}
|
||||
indexPattern={indexPattern}
|
||||
itemsPerPage={5}
|
||||
itemsPerPageOptions={[5, 10, 20]}
|
||||
kqlQuery=""
|
||||
notes={{}}
|
||||
onChangeDataProviderKqlQuery={noop}
|
||||
onChangeDroppableAndProvider={noop}
|
||||
onChangeItemsPerPage={noop}
|
||||
onColumnSorted={noop}
|
||||
onDataProviderRemoved={noop}
|
||||
onFilterChange={noop}
|
||||
onPinEvent={noop}
|
||||
onRangeSelected={noop}
|
||||
onToggleDataProviderEnabled={noop}
|
||||
onToggleDataProviderExcluded={mockOnToggleDataProviderExcluded}
|
||||
onUnPinEvent={noop}
|
||||
pinnedEventIds={{}}
|
||||
range={'1 Day'}
|
||||
rowRenderers={rowRenderers}
|
||||
show={true}
|
||||
sort={sort}
|
||||
indexPattern={indexPattern}
|
||||
updateNote={noop}
|
||||
/>
|
||||
</MockedProvider>
|
||||
</DragDropContext>
|
||||
|
@ -546,7 +600,7 @@ describe('Timeline', () => {
|
|||
dataProviders[0].and = mockDataProviders.slice(1, 3);
|
||||
|
||||
// for this test, all columns have text filters
|
||||
const allColumnsHaveTextFilters = headers.map(header => ({
|
||||
const allColumnsHaveTextFilters = defaultHeaders.map(header => ({
|
||||
...header,
|
||||
columnHeaderType: 'text-filter' as ColumnHeaderType,
|
||||
}));
|
||||
|
@ -558,29 +612,35 @@ describe('Timeline', () => {
|
|||
<DragDropContext onDragEnd={noop}>
|
||||
<MockedProvider mocks={mocks}>
|
||||
<Timeline
|
||||
addNoteToEvent={noop}
|
||||
id="foo"
|
||||
columnHeaders={allColumnsHaveTextFilters}
|
||||
columnRenderers={columnRenderers}
|
||||
dataProviders={dataProviders}
|
||||
flyoutHeight={testFlyoutHeight}
|
||||
flyoutHeaderHeight={flyoutHeaderHeight}
|
||||
indexPattern={indexPattern}
|
||||
itemsPerPage={5}
|
||||
itemsPerPageOptions={[5, 10, 20]}
|
||||
kqlQuery=""
|
||||
notes={{}}
|
||||
onChangeDataProviderKqlQuery={noop}
|
||||
onChangeDroppableAndProvider={noop}
|
||||
onChangeItemsPerPage={noop}
|
||||
onColumnSorted={noop}
|
||||
onDataProviderRemoved={noop}
|
||||
onFilterChange={noop}
|
||||
onPinEvent={noop}
|
||||
onRangeSelected={noop}
|
||||
onToggleDataProviderEnabled={noop}
|
||||
onToggleDataProviderExcluded={noop}
|
||||
onUnPinEvent={noop}
|
||||
pinnedEventIds={{}}
|
||||
range={'1 Day'}
|
||||
rowRenderers={rowRenderers}
|
||||
show={true}
|
||||
sort={sort}
|
||||
indexPattern={indexPattern}
|
||||
updateNote={noop}
|
||||
/>
|
||||
</MockedProvider>
|
||||
</DragDropContext>
|
||||
|
@ -602,7 +662,7 @@ describe('Timeline', () => {
|
|||
const mockOnDataProviderRemoved = jest.fn();
|
||||
|
||||
// for this test, all columns have text filters
|
||||
const allColumnsHaveTextFilters = headers.map(header => ({
|
||||
const allColumnsHaveTextFilters = defaultHeaders.map(header => ({
|
||||
...header,
|
||||
columnHeaderType: 'text-filter' as ColumnHeaderType,
|
||||
}));
|
||||
|
@ -614,29 +674,35 @@ describe('Timeline', () => {
|
|||
<DragDropContext onDragEnd={noop}>
|
||||
<MockedProvider mocks={mocks}>
|
||||
<Timeline
|
||||
addNoteToEvent={noop}
|
||||
id="foo"
|
||||
columnHeaders={allColumnsHaveTextFilters}
|
||||
columnRenderers={columnRenderers}
|
||||
dataProviders={dataProviders}
|
||||
flyoutHeight={testFlyoutHeight}
|
||||
flyoutHeaderHeight={flyoutHeaderHeight}
|
||||
indexPattern={indexPattern}
|
||||
itemsPerPage={5}
|
||||
itemsPerPageOptions={[5, 10, 20]}
|
||||
kqlQuery=""
|
||||
notes={{}}
|
||||
onChangeDataProviderKqlQuery={noop}
|
||||
onChangeDroppableAndProvider={noop}
|
||||
onChangeItemsPerPage={noop}
|
||||
onColumnSorted={noop}
|
||||
onDataProviderRemoved={mockOnDataProviderRemoved}
|
||||
onFilterChange={noop}
|
||||
onPinEvent={noop}
|
||||
onRangeSelected={noop}
|
||||
onToggleDataProviderEnabled={noop}
|
||||
onToggleDataProviderExcluded={noop}
|
||||
onUnPinEvent={noop}
|
||||
pinnedEventIds={{}}
|
||||
range={'1 Day'}
|
||||
rowRenderers={rowRenderers}
|
||||
show={true}
|
||||
sort={sort}
|
||||
indexPattern={indexPattern}
|
||||
updateNote={noop}
|
||||
/>
|
||||
</MockedProvider>
|
||||
</DragDropContext>
|
||||
|
@ -666,7 +732,7 @@ describe('Timeline', () => {
|
|||
const mockOnToggleDataProviderEnabled = jest.fn();
|
||||
|
||||
// for this test, all columns have text filters
|
||||
const allColumnsHaveTextFilters = headers.map(header => ({
|
||||
const allColumnsHaveTextFilters = defaultHeaders.map(header => ({
|
||||
...header,
|
||||
columnHeaderType: 'text-filter' as ColumnHeaderType,
|
||||
}));
|
||||
|
@ -678,29 +744,35 @@ describe('Timeline', () => {
|
|||
<DragDropContext onDragEnd={noop}>
|
||||
<MockedProvider mocks={mocks}>
|
||||
<Timeline
|
||||
addNoteToEvent={noop}
|
||||
id="foo"
|
||||
columnHeaders={allColumnsHaveTextFilters}
|
||||
columnRenderers={columnRenderers}
|
||||
dataProviders={dataProviders}
|
||||
flyoutHeight={testFlyoutHeight}
|
||||
flyoutHeaderHeight={flyoutHeaderHeight}
|
||||
indexPattern={indexPattern}
|
||||
itemsPerPage={5}
|
||||
itemsPerPageOptions={[5, 10, 20]}
|
||||
kqlQuery=""
|
||||
notes={{}}
|
||||
onChangeDataProviderKqlQuery={noop}
|
||||
onChangeDroppableAndProvider={noop}
|
||||
onChangeItemsPerPage={noop}
|
||||
onColumnSorted={noop}
|
||||
onDataProviderRemoved={noop}
|
||||
onFilterChange={noop}
|
||||
onPinEvent={noop}
|
||||
onRangeSelected={noop}
|
||||
onToggleDataProviderEnabled={mockOnToggleDataProviderEnabled}
|
||||
onToggleDataProviderExcluded={noop}
|
||||
onUnPinEvent={noop}
|
||||
pinnedEventIds={{}}
|
||||
range={'1 Day'}
|
||||
rowRenderers={rowRenderers}
|
||||
show={true}
|
||||
sort={sort}
|
||||
indexPattern={indexPattern}
|
||||
updateNote={noop}
|
||||
/>
|
||||
</MockedProvider>
|
||||
</DragDropContext>
|
||||
|
@ -734,7 +806,7 @@ describe('Timeline', () => {
|
|||
const mockOnToggleDataProviderExcluded = jest.fn();
|
||||
|
||||
// for this test, all columns have text filters
|
||||
const allColumnsHaveTextFilters = headers.map(header => ({
|
||||
const allColumnsHaveTextFilters = defaultHeaders.map(header => ({
|
||||
...header,
|
||||
columnHeaderType: 'text-filter' as ColumnHeaderType,
|
||||
}));
|
||||
|
@ -746,29 +818,35 @@ describe('Timeline', () => {
|
|||
<DragDropContext onDragEnd={noop}>
|
||||
<MockedProvider mocks={mocks}>
|
||||
<Timeline
|
||||
addNoteToEvent={noop}
|
||||
id="foo"
|
||||
columnHeaders={allColumnsHaveTextFilters}
|
||||
columnRenderers={columnRenderers}
|
||||
dataProviders={dataProviders}
|
||||
flyoutHeight={testFlyoutHeight}
|
||||
flyoutHeaderHeight={flyoutHeaderHeight}
|
||||
indexPattern={indexPattern}
|
||||
itemsPerPage={5}
|
||||
itemsPerPageOptions={[5, 10, 20]}
|
||||
kqlQuery=""
|
||||
notes={{}}
|
||||
onChangeDataProviderKqlQuery={noop}
|
||||
onChangeDroppableAndProvider={noop}
|
||||
onChangeItemsPerPage={noop}
|
||||
onColumnSorted={noop}
|
||||
onDataProviderRemoved={noop}
|
||||
onFilterChange={noop}
|
||||
onPinEvent={noop}
|
||||
onRangeSelected={noop}
|
||||
onToggleDataProviderEnabled={noop}
|
||||
onToggleDataProviderExcluded={mockOnToggleDataProviderExcluded}
|
||||
onUnPinEvent={noop}
|
||||
pinnedEventIds={{}}
|
||||
range={'1 Day'}
|
||||
rowRenderers={rowRenderers}
|
||||
show={true}
|
||||
sort={sort}
|
||||
indexPattern={indexPattern}
|
||||
updateNote={noop}
|
||||
/>
|
||||
</MockedProvider>
|
||||
</DragDropContext>
|
||||
|
|
|
@ -7,11 +7,13 @@ import { getOr } from 'lodash/fp';
|
|||
import * as React from 'react';
|
||||
import { pure } from 'recompose';
|
||||
import styled from 'styled-components';
|
||||
import { StaticIndexPattern } from 'ui/index_patterns';
|
||||
|
||||
import { StaticIndexPattern } from 'ui/index_patterns';
|
||||
import { TimelineQuery } from '../../containers/timeline';
|
||||
import { Direction } from '../../graphql/types';
|
||||
import { Note } from '../../lib/note';
|
||||
import { AutoSizer } from '../auto_sizer';
|
||||
import { AddNoteToEvent, UpdateNote } from '../notes/helpers';
|
||||
import { Body } from './body';
|
||||
import { ColumnHeader } from './body/column_headers/column_header';
|
||||
import { RowRenderer } from './body/renderers';
|
||||
|
@ -25,15 +27,27 @@ import {
|
|||
OnColumnSorted,
|
||||
OnDataProviderRemoved,
|
||||
OnFilterChange,
|
||||
OnPinEvent,
|
||||
OnRangeSelected,
|
||||
OnToggleDataProviderEnabled,
|
||||
OnToggleDataProviderExcluded,
|
||||
OnUnPinEvent,
|
||||
} from './events';
|
||||
import { Footer, footerHeight } from './footer';
|
||||
import { TimelineHeader } from './header/timeline_header';
|
||||
import { calculateBodyHeight, combineQueries } from './helpers';
|
||||
|
||||
const WrappedByAutoSizer = styled.div`
|
||||
width: auto;
|
||||
`; // required by AutoSizer
|
||||
|
||||
const TimelineContainer = styled.div`
|
||||
padding 0 5px 0 5px;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
addNoteToEvent: AddNoteToEvent;
|
||||
columnHeaders: ColumnHeader[];
|
||||
columnRenderers: ColumnRenderer[];
|
||||
dataProviders: DataProvider[];
|
||||
|
@ -44,28 +58,30 @@ interface Props {
|
|||
itemsPerPage: number;
|
||||
itemsPerPageOptions: number[];
|
||||
kqlQuery: string;
|
||||
notes: { [eventId: string]: Note[] };
|
||||
onChangeDataProviderKqlQuery: OnChangeDataProviderKqlQuery;
|
||||
onChangeDroppableAndProvider: OnChangeDroppableAndProvider;
|
||||
onChangeItemsPerPage: OnChangeItemsPerPage;
|
||||
onColumnSorted: OnColumnSorted;
|
||||
onDataProviderRemoved: OnDataProviderRemoved;
|
||||
onFilterChange: OnFilterChange;
|
||||
onPinEvent: OnPinEvent;
|
||||
onRangeSelected: OnRangeSelected;
|
||||
onToggleDataProviderEnabled: OnToggleDataProviderEnabled;
|
||||
onToggleDataProviderExcluded: OnToggleDataProviderExcluded;
|
||||
onUnPinEvent: OnUnPinEvent;
|
||||
pinnedEventIds: { [eventId: string]: boolean };
|
||||
range: string;
|
||||
rowRenderers: RowRenderer[];
|
||||
show: boolean;
|
||||
sort: Sort;
|
||||
updateNote: UpdateNote;
|
||||
}
|
||||
|
||||
const WrappedByAutoSizer = styled.div`
|
||||
width: auto;
|
||||
`; // required by AutoSizer
|
||||
|
||||
/** The parent Timeline component */
|
||||
export const Timeline = pure<Props>(
|
||||
({
|
||||
addNoteToEvent,
|
||||
columnHeaders,
|
||||
columnRenderers,
|
||||
dataProviders,
|
||||
|
@ -76,96 +92,104 @@ export const Timeline = pure<Props>(
|
|||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
kqlQuery,
|
||||
notes,
|
||||
onChangeDataProviderKqlQuery,
|
||||
onChangeDroppableAndProvider,
|
||||
onChangeItemsPerPage,
|
||||
onColumnSorted,
|
||||
onDataProviderRemoved,
|
||||
onFilterChange,
|
||||
onPinEvent,
|
||||
onRangeSelected,
|
||||
onToggleDataProviderEnabled,
|
||||
onToggleDataProviderExcluded,
|
||||
onUnPinEvent,
|
||||
pinnedEventIds,
|
||||
range,
|
||||
rowRenderers,
|
||||
show,
|
||||
sort,
|
||||
updateNote,
|
||||
}) => {
|
||||
const combinedQueries = combineQueries(dataProviders, indexPattern, kqlQuery);
|
||||
return (
|
||||
<>
|
||||
<AutoSizer detectAnyWindowResize={true} content>
|
||||
{({ measureRef, content: { height: timelineHeaderHeight = 0 } }) => (
|
||||
<>
|
||||
<WrappedByAutoSizer innerRef={measureRef}>
|
||||
<TimelineHeader
|
||||
columnHeaders={columnHeaders}
|
||||
id={id}
|
||||
indexPattern={indexPattern}
|
||||
dataProviders={dataProviders}
|
||||
onChangeDataProviderKqlQuery={onChangeDataProviderKqlQuery}
|
||||
onChangeDroppableAndProvider={onChangeDroppableAndProvider}
|
||||
onColumnSorted={onColumnSorted}
|
||||
onDataProviderRemoved={onDataProviderRemoved}
|
||||
onFilterChange={onFilterChange}
|
||||
onRangeSelected={onRangeSelected}
|
||||
onToggleDataProviderEnabled={onToggleDataProviderEnabled}
|
||||
onToggleDataProviderExcluded={onToggleDataProviderExcluded}
|
||||
range={range}
|
||||
show={show}
|
||||
sort={sort}
|
||||
/>
|
||||
</WrappedByAutoSizer>
|
||||
<AutoSizer detectAnyWindowResize={true} content>
|
||||
{({ measureRef, content: { height: timelineHeaderHeight = 0, width = 0 } }) => (
|
||||
<TimelineContainer data-test-subj="timeline">
|
||||
<WrappedByAutoSizer innerRef={measureRef}>
|
||||
<TimelineHeader
|
||||
id={id}
|
||||
indexPattern={indexPattern}
|
||||
dataProviders={dataProviders}
|
||||
onChangeDataProviderKqlQuery={onChangeDataProviderKqlQuery}
|
||||
onChangeDroppableAndProvider={onChangeDroppableAndProvider}
|
||||
onDataProviderRemoved={onDataProviderRemoved}
|
||||
onToggleDataProviderEnabled={onToggleDataProviderEnabled}
|
||||
onToggleDataProviderExcluded={onToggleDataProviderExcluded}
|
||||
show={show}
|
||||
sort={sort}
|
||||
/>
|
||||
</WrappedByAutoSizer>
|
||||
|
||||
<div data-test-subj="timeline">
|
||||
{combinedQueries != null ? (
|
||||
<TimelineQuery
|
||||
sourceId="default"
|
||||
limit={itemsPerPage}
|
||||
filterQuery={combinedQueries.filterQuery}
|
||||
sortField={{
|
||||
sortFieldId: sort.columnId,
|
||||
direction: sort.sortDirection as Direction,
|
||||
}}
|
||||
>
|
||||
{({ events, loading, totalCount, pageInfo, loadMore, updatedAt }) => (
|
||||
<>
|
||||
<Body
|
||||
id={id}
|
||||
columnHeaders={columnHeaders}
|
||||
columnRenderers={columnRenderers}
|
||||
data={events}
|
||||
height={calculateBodyHeight({
|
||||
flyoutHeight,
|
||||
flyoutHeaderHeight,
|
||||
timelineHeaderHeight,
|
||||
timelineFooterHeight: footerHeight,
|
||||
})}
|
||||
rowRenderers={rowRenderers}
|
||||
/>
|
||||
<Footer
|
||||
dataProviders={dataProviders}
|
||||
serverSideEventCount={totalCount}
|
||||
hasNextPage={getOr(false, 'hasNextPage', pageInfo)!}
|
||||
height={footerHeight}
|
||||
isLoading={loading}
|
||||
itemsCount={events.length}
|
||||
itemsPerPage={itemsPerPage}
|
||||
itemsPerPageOptions={itemsPerPageOptions}
|
||||
onChangeItemsPerPage={onChangeItemsPerPage}
|
||||
onLoadMore={loadMore}
|
||||
nextCursor={getOr(null, 'endCursor.value', pageInfo)!}
|
||||
tieBreaker={getOr(null, 'endCursor.tiebreaker', pageInfo)!}
|
||||
updatedAt={updatedAt}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TimelineQuery>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</>
|
||||
{combinedQueries != null ? (
|
||||
<TimelineQuery
|
||||
sourceId="default"
|
||||
limit={itemsPerPage}
|
||||
filterQuery={combinedQueries.filterQuery}
|
||||
sortField={{
|
||||
sortFieldId: sort.columnId,
|
||||
direction: sort.sortDirection as Direction,
|
||||
}}
|
||||
>
|
||||
{({ events, loading, totalCount, pageInfo, loadMore, updatedAt }) => (
|
||||
<>
|
||||
<Body
|
||||
addNoteToEvent={addNoteToEvent}
|
||||
id={id}
|
||||
columnHeaders={columnHeaders}
|
||||
columnRenderers={columnRenderers}
|
||||
data={events}
|
||||
height={calculateBodyHeight({
|
||||
flyoutHeight,
|
||||
flyoutHeaderHeight,
|
||||
timelineHeaderHeight,
|
||||
timelineFooterHeight: footerHeight,
|
||||
})}
|
||||
notes={notes}
|
||||
onColumnSorted={onColumnSorted}
|
||||
onFilterChange={onFilterChange}
|
||||
onPinEvent={onPinEvent}
|
||||
onRangeSelected={onRangeSelected}
|
||||
onUnPinEvent={onUnPinEvent}
|
||||
pinnedEventIds={pinnedEventIds}
|
||||
range={range}
|
||||
rowRenderers={rowRenderers}
|
||||
sort={sort}
|
||||
updateNote={updateNote}
|
||||
/>
|
||||
<Footer
|
||||
dataProviders={dataProviders}
|
||||
serverSideEventCount={totalCount}
|
||||
hasNextPage={getOr(false, 'hasNextPage', pageInfo)!}
|
||||
height={footerHeight}
|
||||
isLoading={loading}
|
||||
itemsCount={events.length}
|
||||
itemsPerPage={itemsPerPage}
|
||||
itemsPerPageOptions={itemsPerPageOptions}
|
||||
onChangeItemsPerPage={onChangeItemsPerPage}
|
||||
onLoadMore={loadMore}
|
||||
nextCursor={getOr(null, 'endCursor.value', pageInfo)!}
|
||||
tieBreaker={getOr(null, 'endCursor.tiebreaker', pageInfo)!}
|
||||
updatedAt={updatedAt}
|
||||
width={width}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TimelineQuery>
|
||||
) : null}
|
||||
</TimelineContainer>
|
||||
)}
|
||||
</AutoSizer>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -9,18 +9,27 @@ import { pure } from 'recompose';
|
|||
import styled from 'styled-components';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Always show the hover menu contents (default: false)
|
||||
*/
|
||||
alwaysShow?: boolean;
|
||||
/**
|
||||
* The contents of the hover menu. It is highly recommended you wrap this
|
||||
* content in a `div` with `position: absolute` to prevent it from effecting
|
||||
* layout, and to adjust it's position via `top` and `left`
|
||||
* layout, and to adjust it's position via `top` and `left`.
|
||||
*/
|
||||
hoverContent: JSX.Element;
|
||||
/** The content that will be wrapped with hover actions */
|
||||
children: JSX.Element;
|
||||
hoverContent?: JSX.Element;
|
||||
/**
|
||||
* The content that will be wrapped with hover actions. In addition to
|
||||
* rendering the `hoverContent` when the user hovers, this render prop
|
||||
* passes `showHoverContent` to provide a signal that it is in the hover
|
||||
* state.
|
||||
*/
|
||||
render: (showHoverContent: boolean) => JSX.Element;
|
||||
}
|
||||
|
||||
interface State {
|
||||
showActions: boolean;
|
||||
showHoverContent: boolean;
|
||||
}
|
||||
|
||||
const HoverActionsPanelContainer = styled.div`
|
||||
|
@ -39,30 +48,41 @@ const WithHoverActionsContainer = styled.div`
|
|||
flex-direction: row;
|
||||
`;
|
||||
|
||||
/** A HOC that decorates it's children with actions that are visible on hover */
|
||||
/**
|
||||
* Decorates it's children with actions that are visible on hover.
|
||||
* This component does not enforce an opinion on the styling and
|
||||
* positioning of the hover content, but see the documentation for
|
||||
* the `hoverContent` for tips on (not) effecting layout on-hover.
|
||||
*
|
||||
* In addition to rendering the `hoverContent` prop on hover, this
|
||||
* component also passes `showHoverContent` as a render prop, which
|
||||
* provides a signal to the content that the user is in a hover state.
|
||||
*/
|
||||
export class WithHoverActions extends React.PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = { showActions: false };
|
||||
this.state = { showHoverContent: false };
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { hoverContent, children } = this.props;
|
||||
const { alwaysShow = false, hoverContent, render } = this.props;
|
||||
|
||||
return (
|
||||
<WithHoverActionsContainer onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}>
|
||||
<div>{children}</div>
|
||||
<HoverActionsPanel show={this.state.showActions}>{hoverContent}</HoverActionsPanel>
|
||||
<>{render(this.state.showHoverContent)}</>
|
||||
<HoverActionsPanel show={this.state.showHoverContent || alwaysShow}>
|
||||
{hoverContent != null ? hoverContent : <></>}
|
||||
</HoverActionsPanel>
|
||||
</WithHoverActionsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
private onMouseEnter = () => {
|
||||
this.setState({ showActions: true });
|
||||
this.setState({ showHoverContent: true });
|
||||
};
|
||||
|
||||
private onMouseLeave = () => {
|
||||
this.setState({ showActions: false });
|
||||
this.setState({ showHoverContent: false });
|
||||
};
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import copy from 'copy-to-clipboard';
|
|||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import uuid from 'uuid';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export type OnCopy = (
|
||||
{ content, isSuccess }: { content: string | number; isSuccess: boolean }
|
||||
|
@ -18,30 +19,37 @@ const ToastContainer = styled.div`
|
|||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const CopyClipboardIcon = styled(EuiIcon)`
|
||||
margin-right: 5px;
|
||||
`;
|
||||
|
||||
const getSuccessToast = (content: string | number): Toast => ({
|
||||
interface GetSuccessToastParams {
|
||||
content: string | number;
|
||||
titleSummary?: string;
|
||||
}
|
||||
|
||||
const getSuccessToast = ({ content, titleSummary }: GetSuccessToastParams): Toast => ({
|
||||
id: `copy-success-${uuid.v4()}`,
|
||||
color: 'success',
|
||||
text: (
|
||||
<ToastContainer>
|
||||
<CopyClipboardIcon type="copyClipboard" size="m" />
|
||||
<EuiText>
|
||||
Copied <code>{content}</code> to the clipboard
|
||||
{i18n.COPIED} <code>{content}</code> {i18n.TO_THE_CLIPBOARD}
|
||||
</EuiText>
|
||||
</ToastContainer>
|
||||
),
|
||||
title: `Copied '${content}'`,
|
||||
title: `${i18n.COPIED} ${titleSummary || content}`,
|
||||
});
|
||||
|
||||
interface Props {
|
||||
children: JSX.Element;
|
||||
content: string | number;
|
||||
onCopy?: OnCopy;
|
||||
titleSummary?: string;
|
||||
toastLifeTimeMs?: number;
|
||||
}
|
||||
|
||||
|
@ -76,9 +84,10 @@ export class Clipboard extends React.PureComponent<Props, State> {
|
|||
}
|
||||
|
||||
private onClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
const { content, onCopy } = this.props;
|
||||
const { content, onCopy, titleSummary } = this.props;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const isSuccess = copy(`${content}`, { debug: true });
|
||||
|
||||
|
@ -88,7 +97,7 @@ export class Clipboard extends React.PureComponent<Props, State> {
|
|||
|
||||
if (isSuccess) {
|
||||
this.setState({
|
||||
toasts: [...this.state.toasts, getSuccessToast(content)],
|
||||
toasts: [...this.state.toasts, getSuccessToast({ content, titleSummary })],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
@ -10,6 +10,10 @@ export const COPY = i18n.translate('xpack.secops.clipboard.copy', {
|
|||
defaultMessage: 'Copy',
|
||||
});
|
||||
|
||||
export const TO_CLIPBOARD = i18n.translate('xpack.secops.clipboard.toClipboard', {
|
||||
defaultMessage: 'to clipboard',
|
||||
export const COPIED = i18n.translate('xpack.secops.clipboard.copied', {
|
||||
defaultMessage: 'Copied',
|
||||
});
|
||||
|
||||
export const TO_THE_CLIPBOARD = i18n.translate('xpack.secops.clipboard.to.the.clipboard', {
|
||||
defaultMessage: 'to the clipboard',
|
||||
});
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EuiButtonIcon } from '@elastic/eui';
|
||||
import { EuiIcon } from '@elastic/eui';
|
||||
import * as React from 'react';
|
||||
import { pure } from 'recompose';
|
||||
import styled from 'styled-components';
|
||||
|
@ -23,15 +23,17 @@ const WithCopyToClipboardContainer = styled.div`
|
|||
* Renders `children` with an adjacent icon that when clicked, copies `text` to
|
||||
* the clipboard and displays a confirmation toast
|
||||
*/
|
||||
export const WithCopyToClipboard = pure<{ text: string }>(({ text, children }) => (
|
||||
<WithCopyToClipboardContainer>
|
||||
<>{children}</>
|
||||
<Clipboard content={text}>
|
||||
<EuiButtonIcon
|
||||
color="text"
|
||||
iconType="copyClipboard"
|
||||
aria-label={`${i18n.COPY} ${text} ${i18n.TO_CLIPBOARD}`}
|
||||
/>
|
||||
</Clipboard>
|
||||
</WithCopyToClipboardContainer>
|
||||
));
|
||||
export const WithCopyToClipboard = pure<{ text: string; titleSummary?: string }>(
|
||||
({ text, titleSummary, children }) => (
|
||||
<WithCopyToClipboardContainer>
|
||||
<>{children}</>
|
||||
<Clipboard content={text} titleSummary={titleSummary}>
|
||||
<EuiIcon
|
||||
color="text"
|
||||
type="copyClipboard"
|
||||
aria-label={`${i18n.COPY} ${text} ${i18n.TO_THE_CLIPBOARD}`}
|
||||
/>
|
||||
</Clipboard>
|
||||
</WithCopyToClipboardContainer>
|
||||
)
|
||||
);
|
||||
|
|
|
@ -30,7 +30,6 @@ import { LinkToPage } from '../../components/link_to';
|
|||
import { Navigation } from '../../components/page/navigation';
|
||||
import { RangeDatePicker } from '../../components/range_date_picker';
|
||||
import { StatefulTimeline } from '../../components/timeline';
|
||||
import { headers } from '../../components/timeline/body/column_headers/headers';
|
||||
import { NotFoundPage } from '../404';
|
||||
import { HostsContainer } from '../hosts';
|
||||
import { getBreadcrumbs } from '../hosts/host_details';
|
||||
|
@ -44,19 +43,15 @@ const WrappedByAutoSizer = styled.div`
|
|||
/** Returns true if we are running with the k7 design */
|
||||
const isK7Design = () => chrome.getUiSettingsClient().get('k7design');
|
||||
/** the global Kibana navigation at the top of every page */
|
||||
const globalHeaderHeightPx = isK7Design ? 65 : 0;
|
||||
/** Additional padding applied by EuiFlyout */
|
||||
const additionalEuiFlyoutPadding = 45;
|
||||
const globalHeaderHeightPx = isK7Design ? 48 : 0;
|
||||
|
||||
const calculateFlyoutHeight = ({
|
||||
additionalFlyoutPadding,
|
||||
globalHeaderSize,
|
||||
windowHeight,
|
||||
}: {
|
||||
additionalFlyoutPadding: number;
|
||||
globalHeaderSize: number;
|
||||
windowHeight: number;
|
||||
}): number => Math.max(0, windowHeight - (globalHeaderSize + additionalFlyoutPadding));
|
||||
}): number => Math.max(0, windowHeight - globalHeaderSize);
|
||||
|
||||
export const HomePage = pure(() => (
|
||||
<AutoSizer detectAnyWindowResize={true} content>
|
||||
|
@ -66,7 +61,6 @@ export const HomePage = pure(() => (
|
|||
<DragDropContextWrapper>
|
||||
<Flyout
|
||||
flyoutHeight={calculateFlyoutHeight({
|
||||
additionalFlyoutPadding: additionalEuiFlyoutPadding,
|
||||
globalHeaderSize: globalHeaderHeightPx,
|
||||
windowHeight,
|
||||
})}
|
||||
|
@ -76,12 +70,10 @@ export const HomePage = pure(() => (
|
|||
<StatefulTimeline
|
||||
flyoutHeaderHeight={flyoutHeaderHeight}
|
||||
flyoutHeight={calculateFlyoutHeight({
|
||||
additionalFlyoutPadding: additionalEuiFlyoutPadding,
|
||||
globalHeaderSize: globalHeaderHeightPx,
|
||||
windowHeight,
|
||||
})}
|
||||
id="timeline-1"
|
||||
headers={headers}
|
||||
/>
|
||||
</Flyout>
|
||||
<EuiPageBody>
|
||||
|
|
|
@ -16,7 +16,7 @@ export type KqlMode = 'filter' | 'search';
|
|||
export interface TimelineModel {
|
||||
/** The sources of the event data shown in the timeline */
|
||||
dataProviders: DataProvider[];
|
||||
/** The story told by the events and notes in this timeline */
|
||||
/** A summary of the events and notes in this timeline */
|
||||
description: string;
|
||||
/** A map of events in this timeline to the chronologically ordered notes (in this timeline) associated with the event */
|
||||
eventIdToNoteIds: { [eventId: string]: string[] };
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue