## Summary (#29938)

## Summary
Adds notes to events and closes Timeline issues

* closes [Add Notes to events](https://github.com/elastic/ingest-dev/issues/241)

![01-add-notes-to-events](https://user-images.githubusercontent.com/4459398/52268463-d316d480-28f8-11e9-94d7-93de52746c13.gif)

* closes [Drag and drop values from columns into data providers](https://github.com/elastic/ingest-dev/issues/153)

![02-drag-drop-from-columns](https://user-images.githubusercontent.com/4459398/52268573-1bce8d80-28f9-11e9-9d4a-0ecf22d3dacb.gif)

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

![03-drag-from-suricata](https://user-images.githubusercontent.com/4459398/52206450-83bc9f80-2837-11e9-9307-b6fa5c08bf71.gif)

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

![star-fix](https://user-images.githubusercontent.com/4459398/52206473-99ca6000-2837-11e9-9d1b-bb7c5e917a85.gif)

* 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

![footer](https://user-images.githubusercontent.com/4459398/52269063-5d136d00-28fa-11e9-84d5-b1d431010e51.png)

* updated the search / filter select to indicate whether it is an AND or OR operation
![styling-of-and-or](https://user-images.githubusercontent.com/4459398/52206631-09404f80-2838-11e9-912c-84c1f658d64e.png)

* 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:
Andrew Goldstein 2019-02-05 21:59:41 -07:00 committed by GitHub
parent 7074b57b70
commit f05bf082d9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
76 changed files with 3133 additions and 958 deletions

View file

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

View file

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

View file

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

View file

@ -53,7 +53,7 @@ const ReactDndDropTarget = styled.div<{ isDraggingOver: boolean }>`
display: none !important;
}
}
`
: ''}
> div.timeline-drop-area {

View file

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

View file

@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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');
});
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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"
/>
));

View file

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

View file

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

View file

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

View file

@ -0,0 +1,100 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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',
},
];

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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);
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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: [],
});
});
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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