[SIEM] Changes out event.severity for message in timeline and fixes sorting bug (#38158) (#38228)

## Summary

  * Change out event.severity for message in timeline
  * Fixed critical crash where you should not be able to sort on timeline columns that are not aggregatable
  * https://github.com/elastic/ingest-dev/issues/513
  * https://github.com/elastic/ingest-dev/issues/496

Error toaster fixes:
<img width="348" alt="Screen Shot 2019-06-05 at 1 59 01 PM" src="https://user-images.githubusercontent.com/1151048/58986428-97065a00-879a-11e9-8ff9-003ac29f6d9e.png">

What the timeline looks like with severity replaced with message:
<img width="883" alt="Screen Shot 2019-06-05 at 1 55 19 PM" src="https://user-images.githubusercontent.com/1151048/58986459-a71e3980-879a-11e9-8144-f16f055bd53d.png">

### Checklist

Use ~~strikethroughs~~ to remove checklist items you don't feel are applicable to this PR.

~~- [ ] This was checked for cross-browser compatibility, [including a check against IE11](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility)~~
~~- [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md)~~
~~- [ ] [Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials~~
~~- [ ] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios~~
~~- [ ] This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist)~~

### For maintainers

~~- [ ] This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~~
~~- [ ] This includes a feature addition or change that requires a release note and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~~
This commit is contained in:
Frank Hassanabad 2019-06-06 07:26:16 -06:00 committed by GitHub
parent ad3620eed3
commit 391231b419
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 252 additions and 26 deletions

View file

@ -172,6 +172,7 @@ export const addFieldToTimelineColumns = ({
example: isString(column.example) ? column.example : undefined,
id: fieldId,
type: column.type,
aggregatable: column.aggregatable,
width: DEFAULT_COLUMN_MIN_WIDTH,
},
id: timeline,

View file

@ -63,6 +63,7 @@ export const getColumnHeaderFromBrowserField = ({
example: browserField.example != null ? `${browserField.example}` : undefined,
id: browserField.name || '',
type: browserField.type,
aggregatable: browserField.aggregatable,
width,
});

View file

@ -378,6 +378,7 @@ export class StatefulOpenTimelineComponent extends React.PureComponent<
description: col.description != null ? col.description : undefined,
example: col.example != null ? col.example : undefined,
type: col.type != null ? col.type : undefined,
aggregatable: col.aggregatable != null ? col.aggregatable : undefined,
width:
col.id === '@timestamp'
? DEFAULT_DATE_COLUMN_MIN_WIDTH

View file

@ -390,6 +390,7 @@ exports[`Timeline rendering renders correctly against snapshot 1`] = `
columns={
Array [
Object {
"aggregatable": true,
"category": "base",
"columnHeaderType": "not-filtered",
"description": "Date/time when the event originated.
@ -401,6 +402,7 @@ Required field for all events.",
"width": 240,
},
Object {
"aggregatable": true,
"category": "event",
"columnHeaderType": "not-filtered",
"description": "Severity describes the severity of the event. What the different severity values mean can very different between use cases. It's up to the implementer to make sure severities are consistent across events.",
@ -410,6 +412,7 @@ Required field for all events.",
"width": 180,
},
Object {
"aggregatable": true,
"category": "event",
"columnHeaderType": "not-filtered",
"description": "Event category.
@ -420,6 +423,7 @@ This contains high-level information about the contents of the event. It is more
"width": 180,
},
Object {
"aggregatable": true,
"category": "event",
"columnHeaderType": "not-filtered",
"description": "The action captured by the event.
@ -430,6 +434,7 @@ This describes the information in the event. It is more specific than \`event.ca
"width": 180,
},
Object {
"aggregatable": true,
"category": "host",
"columnHeaderType": "not-filtered",
"description": "Name of the host.
@ -440,6 +445,7 @@ It can contain what \`hostname\` returns on Unix systems, the fully qualified do
"width": 180,
},
Object {
"aggregatable": true,
"category": "source",
"columnHeaderType": "not-filtered",
"description": "IP address of the source.
@ -450,6 +456,7 @@ Can be one or multiple IPv4 or IPv6 addresses.",
"width": 180,
},
Object {
"aggregatable": true,
"category": "destination",
"columnHeaderType": "not-filtered",
"description": "IP address of the destination.
@ -460,6 +467,7 @@ Can be one or multiple IPv4 or IPv6 addresses.",
"width": 180,
},
Object {
"aggregatable": true,
"category": "user",
"columnHeaderType": "not-filtered",
"description": "Short name or login of the user.",
@ -469,6 +477,7 @@ Can be one or multiple IPv4 or IPv6 addresses.",
"width": 180,
},
Object {
"aggregatable": true,
"category": "base",
"columnHeaderType": "not-filtered",
"description": "Each document has an _id that uniquely identifies it",

View file

@ -397,7 +397,7 @@ exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = `
},
Object {
"columnHeaderType": "not-filtered",
"id": "event.severity",
"id": "message",
"width": 180,
},
Object {

View file

@ -17,5 +17,6 @@ export interface ColumnHeader {
id: ColumnId;
placeholder?: string;
type?: string;
aggregatable?: boolean;
width: number;
}

View file

@ -18,7 +18,7 @@ export const defaultHeaders: ColumnHeader[] = [
},
{
columnHeaderType: defaultColumnHeaderType,
id: 'event.severity',
id: 'message',
width: DEFAULT_COLUMN_MIN_WIDTH,
},
{

View file

@ -147,11 +147,11 @@ describe('Header', () => {
describe('onColumnSorted', () => {
test('it invokes the onColumnSorted callback when the header is clicked', () => {
const mockOnColumnSorted = jest.fn();
const headerSortable = { ...columnHeader, aggregatable: true };
const wrapper = mount(
<TestProviders>
<Header
header={columnHeader}
header={headerSortable}
isLoading={false}
onColumnRemoved={jest.fn()}
onColumnResized={jest.fn()}
@ -172,6 +172,81 @@ describe('Header', () => {
sortDirection: 'asc', // (because the previous state was Direction.desc)
});
});
test('it does NOT invoke the onColumnSorted callback when the header is clicked and aggregatable is false', () => {
const mockOnColumnSorted = jest.fn();
const headerSortable = { ...columnHeader, aggregatable: false };
const wrapper = mount(
<TestProviders>
<Header
header={headerSortable}
isLoading={false}
onColumnRemoved={jest.fn()}
onColumnResized={jest.fn()}
onColumnSorted={mockOnColumnSorted}
sort={sort}
timelineId={timelineId}
/>
</TestProviders>
);
wrapper
.find('[data-test-subj="header"]')
.first()
.simulate('click');
expect(mockOnColumnSorted).not.toHaveBeenCalled();
});
test('it does NOT invoke the onColumnSorted callback when the header is clicked and aggregatable is missing', () => {
const mockOnColumnSorted = jest.fn();
const headerSortable = { ...columnHeader };
const wrapper = mount(
<TestProviders>
<Header
header={headerSortable}
isLoading={false}
onColumnRemoved={jest.fn()}
onColumnResized={jest.fn()}
onColumnSorted={mockOnColumnSorted}
sort={sort}
timelineId={timelineId}
/>
</TestProviders>
);
wrapper
.find('[data-test-subj="header"]')
.first()
.simulate('click');
expect(mockOnColumnSorted).not.toHaveBeenCalled();
});
test('it does NOT invoke the onColumnSorted callback when the header is clicked and aggregatable is undefined', () => {
const mockOnColumnSorted = jest.fn();
const headerSortable = { ...columnHeader, aggregatable: undefined };
const wrapper = mount(
<TestProviders>
<Header
header={headerSortable}
isLoading={false}
onColumnRemoved={jest.fn()}
onColumnResized={jest.fn()}
onColumnSorted={mockOnColumnSorted}
sort={sort}
timelineId={timelineId}
/>
</TestProviders>
);
wrapper
.find('[data-test-subj="header"]')
.first()
.simulate('click');
expect(mockOnColumnSorted).not.toHaveBeenCalled();
});
});
describe('CloseButton', () => {

View file

@ -149,7 +149,7 @@ export class Header extends React.PureComponent<Props> {
private onClick = () => {
const { header, isLoading, onColumnSorted, sort } = this.props;
if (!isLoading) {
if (!isLoading && header.aggregatable) {
onColumnSorted!({
columnId: header.id,
sortDirection: getNewSortDirectionOnClick({

View file

@ -4,6 +4,7 @@ exports[`HeaderToolTipContent it renders the expected table content 1`] = `
<Component
header={
Object {
"aggregatable": true,
"category": "base",
"columnHeaderType": "not-filtered",
"description": "Date/time when the event originated.

View file

@ -8,7 +8,7 @@ exports[`Columns it renders the expected columns 1`] = `
Array [
Object {
"columnHeaderType": "not-filtered",
"id": "event.severity",
"id": "message",
"width": 180,
},
Object {

View file

@ -2,24 +2,18 @@
exports[`get_column_renderer renders correctly against snapshot 1`] = `
<span>
<Connect(DraggableWrapperComponent)
dataProvider={
Object {
"and": Array [],
"enabled": true,
"excluded": false,
"id": "id-timeline-column-event_severity-for-event-1-event_severity-3",
"kqlQuery": "",
"name": "event.severity: 3",
"queryMatch": Object {
"field": "event.severity",
"operator": ":",
"value": "3",
},
}
}
key="timeline-draggable-column-event.severity-for-event-1-event.severity--3"
render={[Function]}
/>
<EuiText
data-test-subj="draggable-content"
key="timeline-draggable-column-event.severity-for-event-1-message--3"
size="s"
>
<pure(Component)
contextId="plain_column_renderer"
eventId="1"
fieldName="event.severity"
fieldType=""
value="3"
/>
</EuiText>
</span>
`;

View file

@ -9,7 +9,7 @@ import toJson from 'enzyme-to-json';
import { get } from 'lodash/fp';
import * as React from 'react';
import { mockTimelineData } from '../../../../mock';
import { mockTimelineData, TestProviders } from '../../../../mock';
import { getEmptyValue } from '../../../empty_value';
import { FormattedFieldValue } from './formatted_field';
@ -101,4 +101,75 @@ describe('Events', () => {
expect(wrapper.text()).toEqual(getEmptyValue());
});
test('it renders tooltip for message when it exists', () => {
const wrapper = mount(
<FormattedFieldValue
eventId={mockTimelineData[0].ecs._id}
contextId="test"
fieldName="message"
fieldType="text"
value={'some message'}
/>
);
expect(wrapper.find('[data-test-subj="message-tool-tip"]').exists()).toEqual(true);
});
test('it does NOT render a tooltip for message when it is null', () => {
const wrapper = mount(
<TestProviders>
<FormattedFieldValue
eventId={mockTimelineData[0].ecs._id}
contextId="test"
fieldName="message"
fieldType="text"
value={null}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="message-tool-tip"]').exists()).toEqual(false);
});
test('it does NOT render a tooltip for message when it is undefined', () => {
const wrapper = mount(
<TestProviders>
<FormattedFieldValue
eventId={mockTimelineData[0].ecs._id}
contextId="test"
fieldName="message"
fieldType="text"
value={undefined}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="message-tool-tip"]').exists()).toEqual(false);
});
test('it does NOT render a tooltip for message when it is an empty string', () => {
const wrapper = mount(
<TestProviders>
<FormattedFieldValue
eventId={mockTimelineData[0].ecs._id}
contextId="test"
fieldName="message"
fieldType="text"
value={''}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="message-tool-tip"]').exists()).toEqual(false);
});
test('it renders a message text string', () => {
const wrapper = mount(
<FormattedFieldValue
eventId={mockTimelineData[0].ecs._id}
contextId="test"
fieldName="message"
fieldType="text"
value={'some message'}
/>
);
expect(wrapper.text()).toEqual('some message');
});
});

View file

@ -8,6 +8,7 @@ import * as React from 'react';
import { pure } from 'recompose';
import { isNumber } from 'lodash/fp';
import { EuiToolTip, EuiFlexItem, EuiFlexGroup } from '@elastic/eui';
import { Duration, EVENT_DURATION_FIELD_NAME } from '../../../duration';
import { getOrEmptyTagFromValue } from '../../../empty_value';
@ -17,6 +18,7 @@ import { Port, PORT_NAMES } from '../../../port';
export const DATE_FIELD_TYPE = 'date';
export const IP_FIELD_TYPE = 'ip';
export const MESSAGE_FIELD_NAME = 'message';
export const FormattedFieldValue = pure<{
eventId: string;
@ -44,6 +46,25 @@ export const FormattedFieldValue = pure<{
return (
<Duration contextId={contextId} eventId={eventId} fieldName={fieldName} value={`${value}`} />
);
} else if (fieldName === MESSAGE_FIELD_NAME && value != null && value !== '') {
return (
<EuiToolTip
position="left"
data-test-subj="message-tool-tip"
content={
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem grow={false}>
<span>{fieldName}</span>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<span>{value}</span>
</EuiFlexItem>
</EuiFlexGroup>
}
>
<>{value}</>
</EuiToolTip>
);
} else {
return getOrEmptyTagFromValue(value);
}

View file

@ -7,6 +7,7 @@
import { isNumber } from 'lodash/fp';
import React from 'react';
import { EuiText } from '@elastic/eui';
import { TimelineNonEcsData } from '../../../../graphql/types';
import { DragEffects, DraggableWrapper } from '../../../drag_and_drop/draggable_wrapper';
import { escapeDataProviderId } from '../../../drag_and_drop/helpers';
@ -19,6 +20,7 @@ import { IP_FIELD_TYPE, FormattedFieldValue } from './formatted_field';
import { ColumnRenderer } from './column_renderer';
import { parseQueryValue } from './parse_query_value';
import { parseValue } from './parse_value';
import { TruncatableText } from '../../../truncatable_text';
export const dataExistsAtColumn = (columnName: string, data: TimelineNonEcsData[]): boolean =>
data.findIndex(item => item.field === columnName) !== -1;
@ -73,6 +75,45 @@ export const plainColumnRenderer: ColumnRenderer = {
/>
);
}
if (!field.aggregatable) {
if (width != null) {
return (
<TruncatableText
size="s"
width={width}
key={`timeline-draggable-column-${columnName}-for-event-${eventId}-${
field.id
}--${value}`}
>
<FormattedFieldValue
eventId={eventId}
contextId={contextId}
fieldName={columnName}
fieldType={field.type || ''}
value={parseValue(value)}
/>
</TruncatableText>
);
} else {
return (
<EuiText
data-test-subj="draggable-content"
size="s"
key={`timeline-draggable-column-${columnName}-for-event-${eventId}-${
field.id
}--${value}`}
>
<FormattedFieldValue
eventId={eventId}
contextId={contextId}
fieldName={columnName}
fieldType={field.type || ''}
value={parseValue(value)}
/>
</EuiText>
);
}
}
// note: we use a raw DraggableWrapper here instead of a DefaultDraggable,
// because we pass a width to enable text truncation, and we will show empty values
return (

View file

@ -20,6 +20,7 @@ export const defaultHeaders: ColumnHeader[] = [
example: '2016-05-23T08:05:34.853Z',
id: '@timestamp',
type: 'date',
aggregatable: true,
width: DEFAULT_DATE_COLUMN_MIN_WIDTH,
},
{
@ -30,6 +31,7 @@ export const defaultHeaders: ColumnHeader[] = [
example: '7',
id: 'event.severity',
type: 'long',
aggregatable: true,
width: DEFAULT_COLUMN_MIN_WIDTH,
},
{
@ -40,6 +42,7 @@ export const defaultHeaders: ColumnHeader[] = [
example: 'user-management',
id: 'event.category',
type: 'keyword',
aggregatable: true,
width: DEFAULT_COLUMN_MIN_WIDTH,
},
{
@ -50,6 +53,7 @@ export const defaultHeaders: ColumnHeader[] = [
example: 'user-password-change',
id: 'event.action',
type: 'keyword',
aggregatable: true,
width: DEFAULT_COLUMN_MIN_WIDTH,
},
{
@ -60,6 +64,7 @@ export const defaultHeaders: ColumnHeader[] = [
example: '',
id: 'host.name',
type: 'keyword',
aggregatable: true,
width: DEFAULT_COLUMN_MIN_WIDTH,
},
{
@ -69,6 +74,7 @@ export const defaultHeaders: ColumnHeader[] = [
example: '',
id: 'source.ip',
type: 'ip',
aggregatable: true,
width: DEFAULT_COLUMN_MIN_WIDTH,
},
{
@ -78,6 +84,7 @@ export const defaultHeaders: ColumnHeader[] = [
example: '',
id: 'destination.ip',
type: 'ip',
aggregatable: true,
width: DEFAULT_COLUMN_MIN_WIDTH,
},
{
@ -87,6 +94,7 @@ export const defaultHeaders: ColumnHeader[] = [
example: 'albert',
id: 'user.name',
type: 'keyword',
aggregatable: true,
width: DEFAULT_COLUMN_MIN_WIDTH,
},
{
@ -96,6 +104,7 @@ export const defaultHeaders: ColumnHeader[] = [
example: 'Y-6TfmcB0WOhS6qyMv3s',
id: '_id',
type: 'keyword',
aggregatable: true,
width: DEFAULT_COLUMN_MIN_WIDTH,
},
];

View file

@ -171,6 +171,7 @@ describe('Timeline', () => {
example: 'user-password-change',
id: 'event.action',
type: 'keyword',
aggregatable: true,
width: DEFAULT_COLUMN_MIN_WIDTH,
};
});