[SECURITY SOLUTION] Bundles _source -> Fields + able to sort on multiple fields in Timeline (#83761)

* replace _source with fields

* wip

* unit test

* regroup sorting and number together

* fix bugs from review

* mistake

* Update x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx

Co-authored-by: Patryk Kopyciński <contact@patrykkopycinski.com>

* fix snapshot

* review + fix topN and filter from detail view

* fix tests

* fix test

Co-authored-by: Patryk Kopyciński <contact@patrykkopycinski.com>
This commit is contained in:
Xavier Mouligneau 2020-12-09 22:16:38 -05:00 committed by GitHub
parent c9b5ec7303
commit 0f408041b4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
69 changed files with 1521 additions and 655 deletions

View file

@ -28,6 +28,7 @@ export interface EventsActionGroupData {
export interface EventHit extends SearchHit {
sort: string[];
_source: EventSource;
fields: Record<string, unknown[]>;
aggregations: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[agg: string]: any;

View file

@ -31,7 +31,7 @@ export interface TimelineRequestBasicOptions extends IEsSearchRequest {
export interface TimelineRequestOptionsPaginated<Field = string>
extends TimelineRequestBasicOptions {
pagination: Pick<PaginationInputPaginated, 'activePage' | 'querySize'>;
sort: SortField<Field>;
sort: Array<SortField<Field>>;
}
export type TimelineStrategyResponseType<

View file

@ -143,10 +143,15 @@ const SavedFavoriteRuntimeType = runtimeTypes.partial({
/*
* Sort Types
*/
const SavedSortRuntimeType = runtimeTypes.partial({
const SavedSortObject = runtimeTypes.partial({
columnId: unionWithNullType(runtimeTypes.string),
sortDirection: unionWithNullType(runtimeTypes.string),
});
const SavedSortRuntimeType = runtimeTypes.union([
runtimeTypes.array(SavedSortObject),
SavedSortObject,
]);
/*
* Timeline Statuses

View file

@ -157,9 +157,9 @@ describe('Events Viewer', () => {
it('re-orders columns via drag and drop', () => {
const originalColumnOrder =
'@timestampmessagehost.nameevent.moduleevent.datasetevent.actionuser.namesource.ipdestination.ip';
'@timestamp1messagehost.nameevent.moduleevent.datasetevent.actionuser.namesource.ipdestination.ip';
const expectedOrderAfterDragAndDrop =
'message@timestamphost.nameevent.moduleevent.datasetevent.actionuser.namesource.ipdestination.ip';
'message@timestamp1host.nameevent.moduleevent.datasetevent.actionuser.namesource.ipdestination.ip';
cy.get(HEADERS_GROUP).invoke('text').should('equal', originalColumnOrder);
dragAndDropColumn({ column: 0, newPosition: 0 });

View file

@ -1 +1 @@
{"savedObjectId":"0162c130-78be-11ea-9718-118a926974a4","version":"WzcsMV0=","columns":[{"columnHeaderType":"not-filtered","id":"@timestamp"},{"columnHeaderType":"not-filtered","id":"message"},{"columnHeaderType":"not-filtered","id":"event.category"},{"columnHeaderType":"not-filtered","id":"event.action"},{"columnHeaderType":"not-filtered","id":"host.name"},{"columnHeaderType":"not-filtered","id":"source.ip"},{"columnHeaderType":"not-filtered","id":"destination.ip"},{"columnHeaderType":"not-filtered","id":"user.name"}],"created":1586256805054,"createdBy":"elastic","dataProviders":[],"dateRange":{"end":1586256837669,"start":1546343624710},"description":"description","eventType":"all","filters":[],"kqlMode":"filter","kqlQuery":{"filterQuery":{"kuery":{"expression":"host.name:*","kind":"kuery"},"serializedQuery":"{\"bool\":{\"should\":[{\"exists\":{\"field\":\"host.name\"}}],\"minimum_should_match\":1}}"}},"savedQueryId":null,"sort":{"columnId":"@timestamp","sortDirection":"desc"},"title":"SIEM test","updated":1586256839298,"updatedBy":"elastic","timelineType":"default","eventNotes":[],"globalNotes":[],"pinnedEventIds":[]}
{"savedObjectId":"0162c130-78be-11ea-9718-118a926974a4","version":"WzcsMV0=","columns":[{"columnHeaderType":"not-filtered","id":"@timestamp"},{"columnHeaderType":"not-filtered","id":"message"},{"columnHeaderType":"not-filtered","id":"event.category"},{"columnHeaderType":"not-filtered","id":"event.action"},{"columnHeaderType":"not-filtered","id":"host.name"},{"columnHeaderType":"not-filtered","id":"source.ip"},{"columnHeaderType":"not-filtered","id":"destination.ip"},{"columnHeaderType":"not-filtered","id":"user.name"}],"created":1586256805054,"createdBy":"elastic","dataProviders":[],"dateRange":{"end":1586256837669,"start":1546343624710},"description":"description","eventType":"all","filters":[],"kqlMode":"filter","kqlQuery":{"filterQuery":{"kuery":{"expression":"host.name:*","kind":"kuery"},"serializedQuery":"{\"bool\":{\"should\":[{\"exists\":{\"field\":\"host.name\"}}],\"minimum_should_match\":1}}"}},"savedQueryId":null,"sort":[{"columnId":"@timestamp","sortDirection":"desc"}],"title":"SIEM test","updated":1586256839298,"updatedBy":"elastic","timelineType":"default","eventNotes":[],"globalNotes":[],"pinnedEventIds":[]}

View file

@ -19,7 +19,7 @@ import { allowTopN } from './helpers';
import * as i18n from './translations';
import { useManageTimeline } from '../../../timelines/components/manage_timeline';
import { TimelineId } from '../../../../common/types/timeline';
import { SELECTOR_TIMELINE_BODY_CLASS_NAME } from '../../../timelines/components/timeline/styles';
import { SELECTOR_TIMELINE_GLOBAL_CONTAINER } from '../../../timelines/components/timeline/styles';
import { SourcererScopeName } from '../../store/sourcerer/model';
import { useSourcererScope } from '../../containers/sourcerer';
@ -230,7 +230,7 @@ export const useGetTimelineId = function (
if (
myElem != null &&
myElem.classList != null &&
myElem.classList.contains(SELECTOR_TIMELINE_BODY_CLASS_NAME) &&
myElem.classList.contains(SELECTOR_TIMELINE_GLOBAL_CONTAINER) &&
myElem.hasAttribute('data-timeline-id')
) {
setTimelineId(myElem.getAttribute('data-timeline-id'));

View file

@ -79,10 +79,12 @@ const eventsViewerDefaultProps = {
language: 'kql',
},
start: from,
sort: {
columnId: 'foo',
sortDirection: 'none' as SortDirection,
},
sort: [
{
columnId: 'foo',
sortDirection: 'none' as SortDirection,
},
],
scopeId: SourcererScopeName.timeline,
utilityBar,
};

View file

@ -115,7 +115,7 @@ interface Props {
query: Query;
onRuleChange?: () => void;
start: string;
sort: Sort;
sort: Sort[];
utilityBar?: (refetch: inputsModel.Refetch, totalCount: number) => React.ReactNode;
// If truthy, the graph viewer (Resolver) is showing
graphEventId: string | undefined;
@ -202,11 +202,12 @@ const EventsViewerComponent: React.FC<Props> = ({
]);
const sortField = useMemo(
() => ({
field: sort.columnId,
direction: sort.sortDirection as Direction,
}),
[sort.columnId, sort.sortDirection]
() =>
sort.map(({ columnId, sortDirection }) => ({
field: columnId,
direction: sortDirection as Direction,
})),
[sort]
);
const [
@ -341,7 +342,7 @@ export const EventsViewer = React.memo(
prevProps.kqlMode === nextProps.kqlMode &&
deepEqual(prevProps.query, nextProps.query) &&
prevProps.start === nextProps.start &&
prevProps.sort === nextProps.sort &&
deepEqual(prevProps.sort, nextProps.sort) &&
prevProps.utilityBar === nextProps.utilityBar &&
prevProps.graphEventId === nextProps.graphEventId
);

View file

@ -239,7 +239,7 @@ export const mockGlobalState: State = {
pinnedEventIds: {},
pinnedEventsSaveObject: {},
itemsPerPageOptions: [5, 10, 20],
sort: { columnId: '@timestamp', sortDirection: Direction.desc },
sort: [{ columnId: '@timestamp', sortDirection: Direction.desc }],
isSaving: false,
version: null,
status: TimelineStatus.active,

View file

@ -2142,10 +2142,12 @@ export const mockTimelineModel: TimelineModel = {
selectedEventIds: {},
show: false,
showCheckboxes: false,
sort: {
columnId: '@timestamp',
sortDirection: Direction.desc,
},
sort: [
{
columnId: '@timestamp',
sortDirection: Direction.desc,
},
],
status: TimelineStatus.active,
title: 'Test rule',
timelineType: TimelineType.default,
@ -2177,7 +2179,7 @@ export const mockTimelineResult: TimelineResult = {
templateTimelineId: null,
templateTimelineVersion: null,
savedQueryId: null,
sort: { columnId: '@timestamp', sortDirection: 'desc' },
sort: [{ columnId: '@timestamp', sortDirection: 'desc' }],
version: '1',
};
@ -2247,7 +2249,7 @@ export const defaultTimelineProps: CreateTimelineProps = {
selectedEventIds: {},
show: false,
showCheckboxes: false,
sort: { columnId: '@timestamp', sortDirection: Direction.desc },
sort: [{ columnId: '@timestamp', sortDirection: Direction.desc }],
status: TimelineStatus.draft,
title: '',
timelineType: TimelineType.default,

View file

@ -105,80 +105,38 @@ describe('alert actions', () => {
activeTab: TimelineTabs.query,
columns: [
{
aggregatable: undefined,
category: undefined,
columnHeaderType: 'not-filtered',
description: undefined,
example: undefined,
id: '@timestamp',
placeholder: undefined,
type: undefined,
width: 190,
},
{
aggregatable: undefined,
category: undefined,
columnHeaderType: 'not-filtered',
description: undefined,
example: undefined,
id: 'message',
placeholder: undefined,
type: undefined,
width: 180,
},
{
aggregatable: undefined,
category: undefined,
columnHeaderType: 'not-filtered',
description: undefined,
example: undefined,
id: 'event.category',
placeholder: undefined,
type: undefined,
width: 180,
},
{
aggregatable: undefined,
category: undefined,
columnHeaderType: 'not-filtered',
description: undefined,
example: undefined,
id: 'host.name',
placeholder: undefined,
type: undefined,
width: 180,
},
{
aggregatable: undefined,
category: undefined,
columnHeaderType: 'not-filtered',
description: undefined,
example: undefined,
id: 'source.ip',
placeholder: undefined,
type: undefined,
width: 180,
},
{
aggregatable: undefined,
category: undefined,
columnHeaderType: 'not-filtered',
description: undefined,
example: undefined,
id: 'destination.ip',
placeholder: undefined,
type: undefined,
width: 180,
},
{
aggregatable: undefined,
category: undefined,
columnHeaderType: 'not-filtered',
description: undefined,
example: undefined,
id: 'user.name',
placeholder: undefined,
type: undefined,
width: 180,
},
],
@ -242,10 +200,12 @@ describe('alert actions', () => {
selectedEventIds: {},
show: true,
showCheckboxes: false,
sort: {
columnId: '@timestamp',
sortDirection: 'desc',
},
sort: [
{
columnId: '@timestamp',
sortDirection: 'desc',
},
],
status: TimelineStatus.draft,
title: '',
timelineType: TimelineType.default,

View file

@ -2231,7 +2231,7 @@
"name": "sort",
"description": "",
"args": [],
"type": { "kind": "OBJECT", "name": "SortTimelineResult", "ofType": null },
"type": { "kind": "SCALAR", "name": "ToAny", "ofType": null },
"isDeprecated": false,
"deprecationReason": null
},
@ -2953,33 +2953,6 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SortTimelineResult",
"description": "",
"fields": [
{
"name": "columnId",
"description": "",
"args": [],
"type": { "kind": "SCALAR", "name": "String", "ofType": null },
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "sortDirection",
"description": "",
"args": [],
"type": { "kind": "SCALAR", "name": "String", "ofType": null },
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "TimelineStatus",
@ -3650,7 +3623,15 @@
{
"name": "sort",
"description": "",
"type": { "kind": "INPUT_OBJECT", "name": "SortTimelineInput", "ofType": null },
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": { "kind": "INPUT_OBJECT", "name": "SortTimelineInput", "ofType": null }
}
},
"defaultValue": null
},
{

View file

@ -103,7 +103,7 @@ export interface TimelineInput {
savedQueryId?: Maybe<string>;
sort?: Maybe<SortTimelineInput>;
sort?: Maybe<SortTimelineInput[]>;
status?: Maybe<TimelineStatus>;
}
@ -512,17 +512,17 @@ export interface CloudFields {
machine?: Maybe<CloudMachine>;
provider?: Maybe<Maybe<string>[]>;
provider?: Maybe<(Maybe<string>)[]>;
region?: Maybe<Maybe<string>[]>;
region?: Maybe<(Maybe<string>)[]>;
}
export interface CloudInstance {
id?: Maybe<Maybe<string>[]>;
id?: Maybe<(Maybe<string>)[]>;
}
export interface CloudMachine {
type?: Maybe<Maybe<string>[]>;
type?: Maybe<(Maybe<string>)[]>;
}
export interface EndpointFields {
@ -632,7 +632,7 @@ export interface TimelineResult {
savedObjectId: string;
sort?: Maybe<SortTimelineResult>;
sort?: Maybe<ToAny>;
status?: Maybe<TimelineStatus>;
@ -775,14 +775,8 @@ export interface KueryFilterQueryResult {
expression?: Maybe<string>;
}
export interface SortTimelineResult {
columnId?: Maybe<string>;
sortDirection?: Maybe<string>;
}
export interface ResponseTimelines {
timeline: Maybe<TimelineResult>[];
timeline: (Maybe<TimelineResult>)[];
totalCount?: Maybe<number>;
@ -1533,9 +1527,9 @@ export interface HostFields {
id?: Maybe<string>;
ip?: Maybe<Maybe<string>[]>;
ip?: Maybe<(Maybe<string>)[]>;
mac?: Maybe<Maybe<string>[]>;
mac?: Maybe<(Maybe<string>)[]>;
name?: Maybe<string>;
@ -1551,7 +1545,7 @@ export interface IndexField {
/** Example of field's value */
example?: Maybe<string>;
/** whether the field's belong to an alias index */
indexes: Maybe<string>[];
indexes: (Maybe<string>)[];
/** The name of the field */
name: string;
/** The type of the field's values as recognized by Kibana */
@ -1749,7 +1743,7 @@ export namespace GetHostOverviewQuery {
__typename?: 'AgentFields';
id: Maybe<string>;
}
};
export type Host = {
__typename?: 'HostEcsFields';
@ -1788,21 +1782,21 @@ export namespace GetHostOverviewQuery {
machine: Maybe<Machine>;
provider: Maybe<Maybe<string>[]>;
provider: Maybe<(Maybe<string>)[]>;
region: Maybe<Maybe<string>[]>;
region: Maybe<(Maybe<string>)[]>;
};
export type Instance = {
__typename?: 'CloudInstance';
id: Maybe<Maybe<string>[]>;
id: Maybe<(Maybe<string>)[]>;
};
export type Machine = {
__typename?: 'CloudMachine';
type: Maybe<Maybe<string>[]>;
type: Maybe<(Maybe<string>)[]>;
};
export type Inspect = {
@ -1985,7 +1979,7 @@ export namespace GetAllTimeline {
favoriteCount: Maybe<number>;
timeline: Maybe<Timeline>[];
timeline: (Maybe<Timeline>)[];
};
export type Timeline = {
@ -2240,7 +2234,7 @@ export namespace GetOneTimeline {
savedQueryId: Maybe<string>;
sort: Maybe<Sort>;
sort: Maybe<ToAny>;
created: Maybe<number>;
@ -2494,14 +2488,6 @@ export namespace GetOneTimeline {
version: Maybe<string>;
};
export type Sort = {
__typename?: 'SortTimelineResult';
columnId: Maybe<string>;
sortDirection: Maybe<string>;
};
}
export namespace PersistTimelineMutation {
@ -2560,7 +2546,7 @@ export namespace PersistTimelineMutation {
savedQueryId: Maybe<string>;
sort: Maybe<Sort>;
sort: Maybe<ToAny>;
created: Maybe<number>;
@ -2744,14 +2730,6 @@ export namespace PersistTimelineMutation {
end: Maybe<ToAny>;
};
export type Sort = {
__typename?: 'SortTimelineResult';
columnId: Maybe<string>;
sortDirection: Maybe<string>;
};
}
export namespace PersistTimelinePinnedEventMutation {

View file

@ -312,10 +312,12 @@ describe('helpers', () => {
selectedEventIds: {},
show: false,
showCheckboxes: false,
sort: {
columnId: '@timestamp',
sortDirection: 'desc',
},
sort: [
{
columnId: '@timestamp',
sortDirection: 'desc',
},
],
status: TimelineStatus.draft,
title: '',
timelineType: TimelineType.default,
@ -411,10 +413,12 @@ describe('helpers', () => {
selectedEventIds: {},
show: false,
showCheckboxes: false,
sort: {
columnId: '@timestamp',
sortDirection: 'desc',
},
sort: [
{
columnId: '@timestamp',
sortDirection: 'desc',
},
],
status: TimelineStatus.draft,
title: '',
timelineType: TimelineType.template,
@ -510,10 +514,12 @@ describe('helpers', () => {
selectedEventIds: {},
show: false,
showCheckboxes: false,
sort: {
columnId: '@timestamp',
sortDirection: 'desc',
},
sort: [
{
columnId: '@timestamp',
sortDirection: 'desc',
},
],
status: TimelineStatus.draft,
title: '',
timelineType: TimelineType.default,
@ -607,10 +613,12 @@ describe('helpers', () => {
selectedEventIds: {},
show: false,
showCheckboxes: false,
sort: {
columnId: '@timestamp',
sortDirection: 'desc',
},
sort: [
{
columnId: '@timestamp',
sortDirection: 'desc',
},
],
status: TimelineStatus.draft,
title: '',
timelineType: TimelineType.default,
@ -745,10 +753,12 @@ describe('helpers', () => {
selectedEventIds: {},
show: false,
showCheckboxes: false,
sort: {
columnId: '@timestamp',
sortDirection: 'desc',
},
sort: [
{
columnId: '@timestamp',
sortDirection: 'desc',
},
],
status: TimelineStatus.draft,
id: 'savedObject-1',
});
@ -912,10 +922,12 @@ describe('helpers', () => {
selectedEventIds: {},
show: false,
showCheckboxes: false,
sort: {
columnId: '@timestamp',
sortDirection: 'desc',
},
sort: [
{
columnId: '@timestamp',
sortDirection: 'desc',
},
],
status: TimelineStatus.draft,
id: 'savedObject-1',
});
@ -1007,10 +1019,12 @@ describe('helpers', () => {
selectedEventIds: {},
show: false,
showCheckboxes: false,
sort: {
columnId: '@timestamp',
sortDirection: 'desc',
},
sort: [
{
columnId: '@timestamp',
sortDirection: 'desc',
},
],
status: TimelineStatus.immutable,
title: 'Awesome Timeline',
timelineType: TimelineType.template,
@ -1106,10 +1120,12 @@ describe('helpers', () => {
selectedEventIds: {},
show: false,
showCheckboxes: false,
sort: {
columnId: '@timestamp',
sortDirection: 'desc',
},
sort: [
{
columnId: '@timestamp',
sortDirection: 'desc',
},
],
status: TimelineStatus.active,
title: 'Awesome Timeline',
timelineType: TimelineType.default,

View file

@ -108,21 +108,20 @@ const parseString = (params: string) => {
}
};
const setTimelineColumn = (col: ColumnHeaderResult) => {
const timelineCols: ColumnHeaderOptions = {
...col,
columnHeaderType: defaultColumnHeaderType,
id: col.id != null ? col.id : 'unknown',
placeholder: col.placeholder != null ? col.placeholder : undefined,
category: col.category != null ? col.category : undefined,
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 : DEFAULT_COLUMN_MIN_WIDTH,
};
return timelineCols;
};
const setTimelineColumn = (col: ColumnHeaderResult) =>
Object.entries(col).reduce<ColumnHeaderOptions>(
(acc, [key, value]) => {
if (key !== 'id' && value != null) {
return { ...acc, [key]: value };
}
return acc;
},
{
columnHeaderType: defaultColumnHeaderType,
id: col.id != null ? col.id : 'unknown',
width: col.id === '@timestamp' ? DEFAULT_DATE_COLUMN_MIN_WIDTH : DEFAULT_COLUMN_MIN_WIDTH,
}
);
const setTimelineFilters = (filter: FilterTimelineResult) => ({
$state: {

View file

@ -2,7 +2,7 @@
exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = `
<ColumnHeadersComponent
actionsColumnWidth={96}
actionsColumnWidth={120}
browserFields={
Object {
"agent": Object {
@ -465,10 +465,12 @@ exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = `
showEventsSelect={false}
showSelectAllCheckbox={false}
sort={
Object {
"columnId": "fooColumn",
"sortDirection": "desc",
}
Array [
Object {
"columnId": "@timestamp",
"sortDirection": "desc",
},
]
}
timelineId="test"
/>

View file

@ -18,7 +18,7 @@ interface Props {
header: ColumnHeaderOptions;
isLoading: boolean;
onColumnRemoved: OnColumnRemoved;
sort: Sort;
sort: Sort[];
}
/** Given a `header`, returns the `SortDirection` applicable to it */
@ -53,7 +53,7 @@ CloseButton.displayName = 'CloseButton';
export const Actions = React.memo<Props>(({ header, onColumnRemoved, sort, isLoading }) => {
return (
<>
{sort.columnId === header.id && isLoading ? (
{sort.some((i) => i.columnId === header.id) && isLoading ? (
<EventsHeadingExtra className="siemEventsHeading__extra--loading">
<EventsLoading data-test-subj="timeline-loading-spinner" />
</EventsHeadingExtra>

View file

@ -26,7 +26,7 @@ interface ColumneHeaderProps {
header: ColumnHeaderOptions;
isDragging: boolean;
onFilterChange?: OnFilterChange;
sort: Sort;
sort: Sort[];
timelineId: string;
}
@ -131,6 +131,6 @@ export const ColumnHeader = React.memo(
prevProps.timelineId === nextProps.timelineId &&
prevProps.isDragging === nextProps.isDragging &&
prevProps.onFilterChange === nextProps.onFilterChange &&
prevProps.sort === nextProps.sort &&
deepEqual(prevProps.sort, nextProps.sort) &&
deepEqual(prevProps.header, nextProps.header)
);

View file

@ -14,10 +14,12 @@ exports[`Header renders correctly against snapshot 1`] = `
isResizing={false}
onClick={[Function]}
sort={
Object {
"columnId": "@timestamp",
"sortDirection": "desc",
}
Array [
Object {
"columnId": "@timestamp",
"sortDirection": "desc",
},
]
}
>
<Actions
@ -31,10 +33,12 @@ exports[`Header renders correctly against snapshot 1`] = `
isLoading={false}
onColumnRemoved={[Function]}
sort={
Object {
"columnId": "@timestamp",
"sortDirection": "desc",
}
Array [
Object {
"columnId": "@timestamp",
"sortDirection": "desc",
},
]
}
/>
</Memo(HeaderContentComponent)>

View file

@ -14,15 +14,14 @@ import { EventsHeading, EventsHeadingTitleButton, EventsHeadingTitleSpan } from
import { Sort } from '../../sort';
import { SortIndicator } from '../../sort/sort_indicator';
import { HeaderToolTipContent } from '../header_tooltip_content';
import { getSortDirection } from './helpers';
import { getSortDirection, getSortIndex } from './helpers';
interface HeaderContentProps {
children: React.ReactNode;
header: ColumnHeaderOptions;
isLoading: boolean;
isResizing: boolean;
onClick: () => void;
sort: Sort;
sort: Sort[];
}
const HeaderContentComponent: React.FC<HeaderContentProps> = ({
@ -33,7 +32,7 @@ const HeaderContentComponent: React.FC<HeaderContentProps> = ({
onClick,
sort,
}) => (
<EventsHeading data-test-subj="header" isLoading={isLoading}>
<EventsHeading data-test-subj={`header-${header.id}`} isLoading={isLoading}>
{header.aggregatable ? (
<EventsHeadingTitleButton
data-test-subj="header-sort-button"
@ -51,6 +50,7 @@ const HeaderContentComponent: React.FC<HeaderContentProps> = ({
<SortIndicator
data-test-subj="header-sort-indicator"
sortDirection={getSortDirection({ header, sort })}
sortNumber={getSortIndex({ header, sort })}
/>
</EventsHeadingTitleButton>
) : (

View file

@ -11,7 +11,7 @@ import { Sort, SortDirection } from '../../sort';
interface GetNewSortDirectionOnClickParams {
clickedHeader: ColumnHeaderOptions;
currentSort: Sort;
currentSort: Sort[];
}
/** Given a `header`, returns the `SortDirection` applicable to it */
@ -19,7 +19,10 @@ export const getNewSortDirectionOnClick = ({
clickedHeader,
currentSort,
}: GetNewSortDirectionOnClickParams): Direction =>
clickedHeader.id === currentSort.columnId ? getNextSortDirection(currentSort) : Direction.desc;
currentSort.reduce<Direction>(
(acc, item) => (clickedHeader.id === item.columnId ? getNextSortDirection(item) : acc),
Direction.desc
);
/** Given a current sort direction, it returns the next sort direction */
export const getNextSortDirection = (currentSort: Sort): Direction => {
@ -37,8 +40,14 @@ export const getNextSortDirection = (currentSort: Sort): Direction => {
interface GetSortDirectionParams {
header: ColumnHeaderOptions;
sort: Sort;
sort: Sort[];
}
export const getSortDirection = ({ header, sort }: GetSortDirectionParams): SortDirection =>
header.id === sort.columnId ? sort.sortDirection : 'none';
sort.reduce<SortDirection>(
(acc, item) => (header.id === item.columnId ? item.sortDirection : acc),
'none'
);
export const getSortIndex = ({ header, sort }: GetSortDirectionParams): number =>
sort.findIndex((s) => s.columnId === header.id);

View file

@ -32,10 +32,12 @@ const filteredColumnHeader: ColumnHeaderType = 'text-filter';
describe('Header', () => {
const columnHeader = defaultHeaders[0];
const sort: Sort = {
columnId: columnHeader.id,
sortDirection: Direction.desc,
};
const sort: Sort[] = [
{
columnId: columnHeader.id,
sortDirection: Direction.desc,
},
];
const timelineId = 'fakeId';
test('renders correctly against snapshot', () => {
@ -119,10 +121,12 @@ describe('Header', () => {
expect(mockDispatch).toBeCalledWith(
timelineActions.updateSort({
id: timelineId,
sort: {
columnId: columnHeader.id,
sortDirection: Direction.asc, // (because the previous state was Direction.desc)
},
sort: [
{
columnId: columnHeader.id,
sortDirection: Direction.asc, // (because the previous state was Direction.desc)
},
],
})
);
});
@ -158,7 +162,7 @@ describe('Header', () => {
</TestProviders>
);
wrapper.find('[data-test-subj="header"]').first().simulate('click');
wrapper.find(`[data-test-subj="header-${columnHeader.id}"]`).first().simulate('click');
expect(mockOnColumnSorted).not.toHaveBeenCalled();
});
@ -180,14 +184,16 @@ describe('Header', () => {
describe('getSortDirection', () => {
test('it returns the sort direction when the header id matches the sort column id', () => {
expect(getSortDirection({ header: columnHeader, sort })).toEqual(sort.sortDirection);
expect(getSortDirection({ header: columnHeader, sort })).toEqual(sort[0].sortDirection);
});
test('it returns "none" when sort direction when the header id does NOT match the sort column id', () => {
const nonMatching: Sort = {
columnId: 'differentSocks',
sortDirection: Direction.desc,
};
const nonMatching: Sort[] = [
{
columnId: 'differentSocks',
sortDirection: Direction.desc,
},
];
expect(getSortDirection({ header: columnHeader, sort: nonMatching })).toEqual('none');
});
@ -221,10 +227,12 @@ describe('Header', () => {
describe('getNewSortDirectionOnClick', () => {
test('it returns the expected new sort direction when the header id matches the sort column id', () => {
const sortMatches: Sort = {
columnId: columnHeader.id,
sortDirection: Direction.desc,
};
const sortMatches: Sort[] = [
{
columnId: columnHeader.id,
sortDirection: Direction.desc,
},
];
expect(
getNewSortDirectionOnClick({
@ -235,10 +243,12 @@ describe('Header', () => {
});
test('it returns the expected new sort direction when the header id does NOT match the sort column id', () => {
const sortDoesNotMatch: Sort = {
columnId: 'someOtherColumn',
sortDirection: 'none',
};
const sortDoesNotMatch: Sort[] = [
{
columnId: 'someOtherColumn',
sortDirection: 'none',
},
];
expect(
getNewSortDirectionOnClick({

View file

@ -21,7 +21,7 @@ import { useManageTimeline } from '../../../../manage_timeline';
interface Props {
header: ColumnHeaderOptions;
onFilterChange?: OnFilterChange;
sort: Sort;
sort: Sort[];
timelineId: string;
}
@ -33,22 +33,39 @@ export const HeaderComponent: React.FC<Props> = ({
}) => {
const dispatch = useDispatch();
const onClick = useCallback(
() =>
dispatch(
timelineActions.updateSort({
id: timelineId,
sort: {
columnId: header.id,
sortDirection: getNewSortDirectionOnClick({
clickedHeader: header,
currentSort: sort,
}),
},
})
),
[dispatch, header, timelineId, sort]
);
const onColumnSort = useCallback(() => {
const columnId = header.id;
const sortDirection = getNewSortDirectionOnClick({
clickedHeader: header,
currentSort: sort,
});
const headerIndex = sort.findIndex((col) => col.columnId === columnId);
let newSort = [];
if (headerIndex === -1) {
newSort = [
...sort,
{
columnId,
sortDirection,
},
];
} else {
newSort = [
...sort.slice(0, headerIndex),
{
columnId,
sortDirection,
},
...sort.slice(headerIndex + 1),
];
}
dispatch(
timelineActions.updateSort({
id: timelineId,
sort: newSort,
})
);
}, [dispatch, header, sort, timelineId]);
const onColumnRemoved = useCallback(
(columnId) => dispatch(timelineActions.removeColumn({ id: timelineId, columnId })),
@ -68,7 +85,7 @@ export const HeaderComponent: React.FC<Props> = ({
header={header}
isLoading={isLoading}
isResizing={false}
onClick={onClick}
onClick={onColumnSort}
sort={sort}
>
<Actions

View file

@ -17,15 +17,30 @@ import { TestProviders } from '../../../../../common/mock/test_providers';
import { useMountAppended } from '../../../../../common/utils/use_mount_appended';
import { ColumnHeadersComponent } from '.';
import { cloneDeep } from 'lodash/fp';
import { timelineActions } from '../../../../store/timeline';
const mockDispatch = jest.fn();
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
return {
...original,
useDispatch: () => mockDispatch,
};
});
const timelineId = 'test';
describe('ColumnHeaders', () => {
const mount = useMountAppended();
describe('rendering', () => {
const sort: Sort = {
columnId: 'fooColumn',
sortDirection: Direction.desc,
};
const sort: Sort[] = [
{
columnId: '@timestamp',
sortDirection: Direction.desc,
},
];
test('renders correctly against snapshot', () => {
const wrapper = shallow(
@ -39,7 +54,7 @@ describe('ColumnHeaders', () => {
showEventsSelect={false}
showSelectAllCheckbox={false}
sort={sort}
timelineId={'test'}
timelineId={timelineId}
/>
</TestProviders>
);
@ -58,7 +73,7 @@ describe('ColumnHeaders', () => {
showEventsSelect={false}
showSelectAllCheckbox={false}
sort={sort}
timelineId={'test'}
timelineId={timelineId}
/>
</TestProviders>
);
@ -78,7 +93,7 @@ describe('ColumnHeaders', () => {
showEventsSelect={false}
showSelectAllCheckbox={false}
sort={sort}
timelineId={'test'}
timelineId={timelineId}
/>
</TestProviders>
);
@ -88,4 +103,145 @@ describe('ColumnHeaders', () => {
});
});
});
describe('#onColumnsSorted', () => {
let mockSort: Sort[] = [
{
columnId: '@timestamp',
sortDirection: Direction.desc,
},
{
columnId: 'host.name',
sortDirection: Direction.asc,
},
];
let mockDefaultHeaders = cloneDeep(
defaultHeaders.map((h) => (h.id === 'message' ? h : { ...h, aggregatable: true }))
);
beforeEach(() => {
mockDefaultHeaders = cloneDeep(
defaultHeaders.map((h) => (h.id === 'message' ? h : { ...h, aggregatable: true }))
);
mockSort = [
{
columnId: '@timestamp',
sortDirection: Direction.desc,
},
{
columnId: 'host.name',
sortDirection: Direction.asc,
},
];
});
test('Add column `event.category` as desc sorting', () => {
const wrapper = mount(
<TestProviders>
<ColumnHeadersComponent
actionsColumnWidth={DEFAULT_ACTIONS_COLUMN_WIDTH}
browserFields={mockBrowserFields}
columnHeaders={mockDefaultHeaders}
isSelectAllChecked={false}
onSelectAll={jest.fn}
showEventsSelect={false}
showSelectAllCheckbox={false}
sort={mockSort}
timelineId={timelineId}
/>
</TestProviders>
);
wrapper
.find('[data-test-subj="header-event.category"] [data-test-subj="header-sort-button"]')
.first()
.simulate('click');
expect(mockDispatch).toHaveBeenCalledWith(
timelineActions.updateSort({
id: timelineId,
sort: [
{
columnId: '@timestamp',
sortDirection: Direction.desc,
},
{
columnId: 'host.name',
sortDirection: Direction.asc,
},
{ columnId: 'event.category', sortDirection: Direction.desc },
],
})
);
});
test('Change order of column `@timestamp` from desc to asc without changing index position', () => {
const wrapper = mount(
<TestProviders>
<ColumnHeadersComponent
actionsColumnWidth={DEFAULT_ACTIONS_COLUMN_WIDTH}
browserFields={mockBrowserFields}
columnHeaders={mockDefaultHeaders}
isSelectAllChecked={false}
onSelectAll={jest.fn()}
showEventsSelect={false}
showSelectAllCheckbox={false}
sort={mockSort}
timelineId={timelineId}
/>
</TestProviders>
);
wrapper
.find('[data-test-subj="header-@timestamp"] [data-test-subj="header-sort-button"]')
.first()
.simulate('click');
expect(mockDispatch).toHaveBeenCalledWith(
timelineActions.updateSort({
id: timelineId,
sort: [
{
columnId: '@timestamp',
sortDirection: Direction.asc,
},
{ columnId: 'host.name', sortDirection: Direction.asc },
],
})
);
});
test('Change order of column `host.name` from asc to desc without changing index position', () => {
const wrapper = mount(
<TestProviders>
<ColumnHeadersComponent
actionsColumnWidth={DEFAULT_ACTIONS_COLUMN_WIDTH}
browserFields={mockBrowserFields}
columnHeaders={mockDefaultHeaders}
isSelectAllChecked={false}
onSelectAll={jest.fn()}
showEventsSelect={false}
showSelectAllCheckbox={false}
sort={mockSort}
timelineId={timelineId}
/>
</TestProviders>
);
wrapper
.find('[data-test-subj="header-host.name"] [data-test-subj="header-sort-button"]')
.first()
.simulate('click');
expect(mockDispatch).toHaveBeenCalledWith(
timelineActions.updateSort({
id: timelineId,
sort: [
{
columnId: '@timestamp',
sortDirection: Direction.desc,
},
{ columnId: 'host.name', sortDirection: Direction.desc },
],
})
);
});
});
});

View file

@ -4,10 +4,18 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiButtonIcon, EuiCheckbox, EuiToolTip } from '@elastic/eui';
import {
EuiButtonIcon,
EuiCheckbox,
EuiDataGridSorting,
EuiToolTip,
useDataGridColumnSorting,
} from '@elastic/eui';
import deepEqual from 'fast-deep-equal';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Droppable, DraggableChildrenFn } from 'react-beautiful-dnd';
import deepEqual from 'fast-deep-equal';
import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import { DragEffects } from '../../../../../common/components/drag_and_drop/draggable_wrapper';
import { DraggableFieldBadge } from '../../../../../common/components/draggables/field_badge';
@ -34,11 +42,18 @@ import {
EventsThGroupData,
EventsTrHeader,
} from '../../styles';
import { Sort } from '../sort';
import { Sort, SortDirection } from '../sort';
import { EventsSelect } from './events_select';
import { ColumnHeader } from './column_header';
import * as i18n from './translations';
import { timelineActions } from '../../../../store/timeline';
const SortingColumnsContainer = styled.div`
.euiPopover .euiButtonEmpty .euiButtonContent .euiButtonEmpty__text {
display: none;
}
`;
interface Props {
actionsColumnWidth: number;
@ -49,7 +64,7 @@ interface Props {
onSelectAll: OnSelectAll;
showEventsSelect: boolean;
showSelectAllCheckbox: boolean;
sort: Sort;
sort: Sort[];
timelineId: string;
}
@ -98,6 +113,7 @@ export const ColumnHeadersComponent = ({
sort,
timelineId,
}: Props) => {
const dispatch = useDispatch();
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
const {
timelineFullScreen,
@ -189,6 +205,48 @@ export const ColumnHeadersComponent = ({
[ColumnHeaderList]
);
const myColumns = useMemo(
() =>
columnHeaders.map(({ aggregatable, label, id, type }) => ({
id,
isSortable: aggregatable,
displayAsText: label,
schema: type,
})),
[columnHeaders]
);
const onSortColumns = useCallback(
(cols: EuiDataGridSorting['columns']) =>
dispatch(
timelineActions.updateSort({
id: timelineId,
sort: cols.map(({ id, direction }) => ({
columnId: id,
sortDirection: direction as SortDirection,
})),
})
),
[dispatch, timelineId]
);
const sortedColumns = useMemo(
() => ({
onSort: onSortColumns,
columns: sort.map<{ id: string; direction: 'asc' | 'desc' }>(
({ columnId, sortDirection }) => ({
id: columnId,
direction: sortDirection as 'asc' | 'desc',
})
),
}),
[onSortColumns, sort]
);
const displayValues = useMemo(
() => columnHeaders.reduce((acc, ch) => ({ ...acc, [ch.id]: ch.label ?? ch.id }), {}),
[columnHeaders]
);
const ColumnSorting = useDataGridColumnSorting(myColumns, sortedColumns, {}, [], displayValues);
return (
<EventsThead data-test-subj="column-headers">
<EventsTrHeader>
@ -245,6 +303,13 @@ export const ColumnHeadersComponent = ({
</EuiToolTip>
</EventsThContent>
</EventsTh>
<EventsTh>
<EventsThContent textAlign="center" width={DEFAULT_ICON_BUTTON_WIDTH}>
<EuiToolTip content={i18n.SORT_FIELDS}>
<SortingColumnsContainer>{ColumnSorting}</SortingColumnsContainer>
</EuiToolTip>
</EventsThContent>
</EventsTh>
{showEventsSelect && (
<EventsTh>
@ -278,7 +343,7 @@ export const ColumnHeaders = React.memo(
prevProps.onSelectAll === nextProps.onSelectAll &&
prevProps.showEventsSelect === nextProps.showEventsSelect &&
prevProps.showSelectAllCheckbox === nextProps.showSelectAllCheckbox &&
prevProps.sort === nextProps.sort &&
deepEqual(prevProps.sort, nextProps.sort) &&
prevProps.timelineId === nextProps.timelineId &&
deepEqual(prevProps.columnHeaders, nextProps.columnHeaders) &&
deepEqual(prevProps.browserFields, nextProps.browserFields)

View file

@ -22,6 +22,10 @@ export const FULL_SCREEN = i18n.translate('xpack.securitySolution.timeline.fullS
defaultMessage: 'Full screen',
});
export const SORT_FIELDS = i18n.translate('xpack.securitySolution.timeline.sortFieldsButton', {
defaultMessage: 'Sort fields',
});
export const TYPE = i18n.translate('xpack.securitySolution.timeline.typeTooltip', {
defaultMessage: 'Type',
});

View file

@ -7,15 +7,17 @@
/** The minimum (fixed) width of the Actions column */
export const MINIMUM_ACTIONS_COLUMN_WIDTH = 50; // px;
/** Additional column width to include when checkboxes are shown **/
export const SHOW_CHECK_BOXES_COLUMN_WIDTH = 24; // px;
/** The (fixed) width of the Actions column */
export const DEFAULT_ACTIONS_COLUMN_WIDTH = 24 * 4; // px;
export const DEFAULT_ACTIONS_COLUMN_WIDTH = SHOW_CHECK_BOXES_COLUMN_WIDTH * 5; // px;
/**
* The (fixed) width of the Actions column when the timeline body is used as
* an events viewer, which has fewer actions than a regular events viewer
*/
export const EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH = 24 * 3; // px;
/** Additional column width to include when checkboxes are shown **/
export const SHOW_CHECK_BOXES_COLUMN_WIDTH = 24; // px;
export const EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH = SHOW_CHECK_BOXES_COLUMN_WIDTH * 4; // px;
/** The default minimum width of a column (when a width for the column type is not specified) */
export const DEFAULT_COLUMN_MIN_WIDTH = 180; // px
/** The default minimum width of a column of type `date` */

View file

@ -16,13 +16,14 @@ import { TestProviders } from '../../../../common/mock/test_providers';
import { BodyComponent, StatefulBodyProps } from '.';
import { Sort } from './sort';
import { useMountAppended } from '../../../../common/utils/use_mount_appended';
import { SELECTOR_TIMELINE_BODY_CLASS_NAME } from '../styles';
import { timelineActions } from '../../../store/timeline';
const mockSort: Sort = {
columnId: '@timestamp',
sortDirection: Direction.desc,
};
const mockSort: Sort[] = [
{
columnId: '@timestamp',
sortDirection: Direction.desc,
},
];
const mockDispatch = jest.fn();
jest.mock('react-redux', () => {
@ -130,20 +131,6 @@ describe('Body', () => {
});
});
}, 20000);
test(`it add attribute data-timeline-id in ${SELECTOR_TIMELINE_BODY_CLASS_NAME}`, () => {
const wrapper = mount(
<TestProviders>
<BodyComponent {...props} />
</TestProviders>
);
expect(
wrapper
.find(`[data-timeline-id="timeline-test"].${SELECTOR_TIMELINE_BODY_CLASS_NAME}`)
.first()
.exists()
).toEqual(true);
});
});
describe('action on event', () => {

View file

@ -33,7 +33,7 @@ interface OwnProps {
data: TimelineItem[];
id: string;
isEventViewer?: boolean;
sort: Sort;
sort: Sort[];
refetch: inputsModel.Refetch;
onRuleChange?: () => void;
}
@ -144,7 +144,7 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
return (
<>
<TimelineBody data-test-subj="timeline-body" data-timeline-id={id}>
<TimelineBody data-test-subj="timeline-body">
<EventsTable data-test-subj="events-table" columnWidths={columnWidths}>
<ColumnHeaders
actionsColumnWidth={actionsColumnWidth}

View file

@ -7,6 +7,7 @@
export const DATE_FIELD_TYPE = 'date';
export const HOST_NAME_FIELD_NAME = 'host.name';
export const IP_FIELD_TYPE = 'ip';
export const GEO_FIELD_TYPE = 'geo_point';
export const MESSAGE_FIELD_NAME = 'message';
export const EVENT_MODULE_FIELD_NAME = 'event.module';
export const RULE_REFERENCE_FIELD_NAME = 'rule.reference';

View file

@ -31,6 +31,7 @@ import {
SIGNAL_RULE_NAME_FIELD_NAME,
REFERENCE_URL_FIELD_NAME,
EVENT_URL_FIELD_NAME,
GEO_FIELD_TYPE,
} from './constants';
import { RenderRuleName, renderEventModule, renderUrl } from './formatted_field_helpers';
@ -57,6 +58,8 @@ const FormattedFieldValueComponent: React.FC<{
truncate={truncate}
/>
);
} else if (fieldType === GEO_FIELD_TYPE) {
return <>{value}</>;
} else if (fieldType === DATE_FIELD_TYPE) {
return (
<DefaultDraggable

View file

@ -11,5 +11,8 @@ exports[`SortIndicator rendering renders correctly against snapshot 1`] = `
data-test-subj="sortIndicator"
type="sortDown"
/>
<SortNumber
sortNumber={-1}
/>
</EuiToolTip>
`;

View file

@ -15,12 +15,12 @@ import { getDirection, SortIndicator } from './sort_indicator';
describe('SortIndicator', () => {
describe('rendering', () => {
test('renders correctly against snapshot', () => {
const wrapper = shallow(<SortIndicator sortDirection={Direction.desc} />);
const wrapper = shallow(<SortIndicator sortDirection={Direction.desc} sortNumber={-1} />);
expect(wrapper).toMatchSnapshot();
});
test('it renders the expected sort indicator when direction is ascending', () => {
const wrapper = mount(<SortIndicator sortDirection={Direction.asc} />);
const wrapper = mount(<SortIndicator sortDirection={Direction.asc} sortNumber={-1} />);
expect(wrapper.find('[data-test-subj="sortIndicator"]').first().prop('type')).toEqual(
'sortUp'
@ -28,7 +28,7 @@ describe('SortIndicator', () => {
});
test('it renders the expected sort indicator when direction is descending', () => {
const wrapper = mount(<SortIndicator sortDirection={Direction.desc} />);
const wrapper = mount(<SortIndicator sortDirection={Direction.desc} sortNumber={-1} />);
expect(wrapper.find('[data-test-subj="sortIndicator"]').first().prop('type')).toEqual(
'sortDown'
@ -36,7 +36,7 @@ describe('SortIndicator', () => {
});
test('it renders the expected sort indicator when direction is `none`', () => {
const wrapper = mount(<SortIndicator sortDirection="none" />);
const wrapper = mount(<SortIndicator sortDirection="none" sortNumber={-1} />);
expect(wrapper.find('[data-test-subj="sortIndicator"]').first().prop('type')).toEqual(
'empty'
@ -60,7 +60,7 @@ describe('SortIndicator', () => {
describe('sort indicator tooltip', () => {
test('it returns the expected tooltip when the direction is ascending', () => {
const wrapper = mount(<SortIndicator sortDirection={Direction.asc} />);
const wrapper = mount(<SortIndicator sortDirection={Direction.asc} sortNumber={-1} />);
expect(
wrapper.find('[data-test-subj="sort-indicator-tooltip"]').first().props().content
@ -68,7 +68,7 @@ describe('SortIndicator', () => {
});
test('it returns the expected tooltip when the direction is descending', () => {
const wrapper = mount(<SortIndicator sortDirection={Direction.desc} />);
const wrapper = mount(<SortIndicator sortDirection={Direction.desc} sortNumber={-1} />);
expect(
wrapper.find('[data-test-subj="sort-indicator-tooltip"]').first().props().content
@ -76,7 +76,7 @@ describe('SortIndicator', () => {
});
test('it does NOT render a tooltip when sort direction is `none`', () => {
const wrapper = mount(<SortIndicator sortDirection="none" />);
const wrapper = mount(<SortIndicator sortDirection="none" sortNumber={-1} />);
expect(wrapper.find('[data-test-subj="sort-indicator-tooltip"]').exists()).toBe(false);
});

View file

@ -9,6 +9,7 @@ import React from 'react';
import { Direction } from '../../../../../graphql/types';
import * as i18n from '../translations';
import { SortNumber } from './sort_number';
import { SortDirection } from '.';
@ -35,10 +36,11 @@ export const getDirection = (sortDirection: SortDirection): SortDirectionIndicat
interface Props {
sortDirection: SortDirection;
sortNumber: number;
}
/** Renders a sort indicator */
export const SortIndicator = React.memo<Props>(({ sortDirection }) => {
export const SortIndicator = React.memo<Props>(({ sortDirection, sortNumber }) => {
const direction = getDirection(sortDirection);
if (direction != null) {
@ -51,7 +53,10 @@ export const SortIndicator = React.memo<Props>(({ sortDirection }) => {
}
data-test-subj="sort-indicator-tooltip"
>
<EuiIcon data-test-subj="sortIndicator" type={direction} />
<>
<EuiIcon data-test-subj="sortIndicator" type={direction} />
<SortNumber sortNumber={sortNumber} />
</>
</EuiToolTip>
);
} else {

View file

@ -0,0 +1,26 @@
/*
* 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, EuiNotificationBadge } from '@elastic/eui';
import React from 'react';
interface Props {
sortNumber: number;
}
export const SortNumber = React.memo<Props>(({ sortNumber }) => {
if (sortNumber >= 0) {
return (
<EuiNotificationBadge color="subdued" data-test-subj="sortNumber">
{sortNumber + 1}
</EuiNotificationBadge>
);
} else {
return <EuiIcon data-test-subj="sortEmptyNumber" type={'empty'} />;
}
});
SortNumber.displayName = 'SortNumber';

View file

@ -38,6 +38,10 @@ export type OnFilterChange = (filter: { columnId: ColumnId; filter: string }) =>
/** Invoked when a column is sorted */
export type OnColumnSorted = (sorted: { columnId: ColumnId; sortDirection: SortDirection }) => void;
export type OnColumnsSorted = (
sorted: Array<{ columnId: ColumnId; sortDirection: SortDirection }>
) => void;
export type OnColumnRemoved = (columnId: ColumnId) => void;
export type OnColumnResized = ({ columnId, delta }: { columnId: ColumnId; delta: number }) => void;

View file

@ -20,6 +20,7 @@ import {
import { StatefulTimeline, Props as StatefulTimelineOwnProps } from './index';
import { useTimelineEvents } from '../../containers/index';
import { SELECTOR_TIMELINE_GLOBAL_CONTAINER } from './styles';
jest.mock('../../containers/index', () => ({
useTimelineEvents: jest.fn(),
@ -56,7 +57,7 @@ jest.mock('../../../common/containers/sourcerer', () => {
});
describe('StatefulTimeline', () => {
const props: StatefulTimelineOwnProps = {
timelineId: 'id',
timelineId: 'timeline-test',
};
beforeEach(() => {
@ -71,4 +72,18 @@ describe('StatefulTimeline', () => {
);
expect(wrapper.find('[data-test-subj="timeline"]')).toBeTruthy();
});
test(`it add attribute data-timeline-id in ${SELECTOR_TIMELINE_GLOBAL_CONTAINER}`, () => {
const wrapper = mount(
<TestProviders>
<StatefulTimeline {...props} />
</TestProviders>
);
expect(
wrapper
.find(`[data-timeline-id="timeline-test"].${SELECTOR_TIMELINE_GLOBAL_CONTAINER}`)
.first()
.exists()
).toEqual(true);
});
});

View file

@ -21,13 +21,7 @@ import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/h
import { activeTimeline } from '../../containers/active_timeline_context';
import * as i18n from './translations';
import { TabsContent } from './tabs_content';
const TimelineContainer = styled.div`
height: 100%;
display: flex;
flex-direction: column;
position: relative;
`;
import { TimelineContainer } from './styles';
const TimelineTemplateBadge = styled.div`
background: ${({ theme }) => theme.eui.euiColorVis3_behindText};
@ -78,7 +72,7 @@ const StatefulTimelineComponent: React.FC<Props> = ({ timelineId }) => {
}, []);
return (
<TimelineContainer data-test-subj="timeline">
<TimelineContainer data-test-subj="timeline" data-timeline-id={timelineId}>
<TimelineSavingProgress timelineId={timelineId} />
{timelineType === TimelineType.template && (
<TimelineTemplateBadge>{i18n.TIMELINE_TEMPLATE}</TimelineTemplateBadge>

View file

@ -276,10 +276,12 @@ In other use cases the message field can be used to concatenate different values
showCallOutUnauthorizedMsg={false}
showEventDetails={false}
sort={
Object {
"columnId": "@timestamp",
"sortDirection": "desc",
}
Array [
Object {
"columnId": "@timestamp",
"sortDirection": "desc",
},
]
}
start="2018-03-23T18:49:23.132Z"
status="active"

View file

@ -64,10 +64,12 @@ jest.mock('../../../../common/lib/kibana', () => {
describe('Timeline', () => {
let props = {} as QueryTabContentComponentProps;
const sort: Sort = {
columnId: '@timestamp',
sortDirection: Direction.desc,
};
const sort: Sort[] = [
{
columnId: '@timestamp',
sortDirection: Direction.desc,
},
];
const startDate = '2018-03-23T18:49:23.132Z';
const endDate = '2018-03-24T03:33:52.253Z';

View file

@ -214,11 +214,12 @@ export const QueryTabContentComponent: React.FC<Props> = ({
}, [columns]);
const timelineQuerySortField = useMemo(
() => ({
field: sort.columnId,
direction: sort.sortDirection as Direction,
}),
[sort.columnId, sort.sortDirection]
() =>
sort.map(({ columnId, sortDirection }) => ({
field: columnId,
direction: sortDirection as Direction,
})),
[sort]
);
const { initializeTimeline, setIsTimelineLoading } = useManageTimeline();

View file

@ -11,6 +11,19 @@ import styled, { createGlobalStyle } from 'styled-components';
import { TimelineEventsType } from '../../../../common/types/timeline';
import { IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME } from '../../../common/components/drag_and_drop/helpers';
/**
* TIMELINE BODY
*/
export const SELECTOR_TIMELINE_GLOBAL_CONTAINER = 'securitySolutionTimeline__container';
export const TimelineContainer = styled.div.attrs(({ className = '' }) => ({
className: `${SELECTOR_TIMELINE_GLOBAL_CONTAINER} ${className}`,
}))`
height: 100%;
display: flex;
flex-direction: column;
position: relative;
`;
/**
* TIMELINE BODY
*/
@ -99,6 +112,9 @@ export const EventsThGroupActions = styled.div.attrs(({ className = '' }) => ({
min-width: 0;
padding-left: ${({ isEventViewer }) =>
!isEventViewer ? '4px;' : '0;'}; // match timeline event border
button {
color: ${({ theme }) => theme.eui.euiColorPrimary};
}
`;
export const EventsThGroupData = styled.div.attrs(({ className = '' }) => ({

View file

@ -73,10 +73,12 @@ const timelineData = {
end: 1591084965409,
},
savedQueryId: null,
sort: {
columnId: '@timestamp',
sortDirection: 'desc',
},
sort: [
{
columnId: '@timestamp',
sortDirection: 'desc',
},
],
status: TimelineStatus.active,
};
const mockPatchTimelineResponse = {

View file

@ -85,6 +85,9 @@ export const useTimelineEventsDetails = ({
}
},
error: () => {
if (!didCancel) {
setLoading(false);
}
notifications.toasts.addDanger('Failed to run search');
},
});

View file

@ -56,7 +56,7 @@ export interface UseTimelineEventsProps {
fields: string[];
indexNames: string[];
limit: number;
sort: SortField;
sort: SortField[];
startDate: string;
timerangeKind?: 'absolute' | 'relative';
}
@ -65,10 +65,12 @@ const getTimelineEvents = (timelineEdges: TimelineEdges[]): TimelineItem[] =>
timelineEdges.map((e: TimelineEdges) => e.node);
const ID = 'timelineEventsQuery';
export const initSortDefault = {
field: '@timestamp',
direction: Direction.asc,
};
export const initSortDefault = [
{
field: '@timestamp',
direction: Direction.asc,
},
];
function usePreviousRequest(value: TimelineEventsAllRequestOptions | null) {
const ref = useRef<TimelineEventsAllRequestOptions | null>(value);

View file

@ -32,7 +32,12 @@ export const getTimelinesInStorageByIds = (storage: Storage, timelineIds: Timeli
return {
...acc,
[timelineId]: timelineModel,
[timelineId]: {
...timelineModel,
...(timelineModel.sort != null && !Array.isArray(timelineModel.sort)
? { sort: [timelineModel.sort] }
: {}),
},
};
}, {} as { [K in TimelineIdLiteral]: TimelineModel });
};

View file

@ -138,10 +138,7 @@ export const oneTimelineQuery = gql`
templateTimelineId
templateTimelineVersion
savedQueryId
sort {
columnId
sortDirection
}
sort
created
createdBy
updated

View file

@ -102,10 +102,7 @@ export const persistTimelineMutation = gql`
end
}
savedQueryId
sort {
columnId
sortDirection
}
sort
created
createdBy
updated

View file

@ -72,7 +72,7 @@ export interface TimelineInput {
filterQuery: SerializedFilterQuery | null;
};
show?: boolean;
sort?: Sort;
sort?: Sort[];
showCheckboxes?: boolean;
timelineType?: TimelineTypeLiteral;
templateTimelineId?: string | null;
@ -216,7 +216,7 @@ export const updateRange = actionCreator<{ id: string; start: string; end: strin
'UPDATE_RANGE'
);
export const updateSort = actionCreator<{ id: string; sort: Sort }>('UPDATE_SORT');
export const updateSort = actionCreator<{ id: string; sort: Sort[] }>('UPDATE_SORT');
export const updateAutoSaveMsg = actionCreator<{
timelineId: string | null;

View file

@ -52,10 +52,12 @@ export const timelineDefaults: SubsetTimelineModel & Pick<TimelineModel, 'filter
selectedEventIds: {},
show: false,
showCheckboxes: false,
sort: {
columnId: '@timestamp',
sortDirection: Direction.desc,
},
sort: [
{
columnId: '@timestamp',
sortDirection: Direction.desc,
},
],
status: TimelineStatus.draft,
version: null,
};

View file

@ -149,7 +149,7 @@ describe('Epic Timeline', () => {
selectedEventIds: {},
show: true,
showCheckboxes: false,
sort: { columnId: '@timestamp', sortDirection: Direction.desc },
sort: [{ columnId: '@timestamp', sortDirection: Direction.desc }],
status: TimelineStatus.active,
version: 'WzM4LDFd',
id: '11169110-fc22-11e9-8ca9-072f15ce2685',
@ -286,10 +286,12 @@ describe('Epic Timeline', () => {
},
},
savedQueryId: 'my endgame timeline query',
sort: {
columnId: '@timestamp',
sortDirection: 'desc',
},
sort: [
{
columnId: '@timestamp',
sortDirection: 'desc',
},
],
templateTimelineId: null,
templateTimelineVersion: null,
timelineType: TimelineType.default,

View file

@ -58,10 +58,12 @@ describe('epicLocalStorage', () => {
);
let props = {} as QueryTabContentComponentProps;
const sort: Sort = {
columnId: '@timestamp',
sortDirection: Direction.desc,
};
const sort: Sort[] = [
{
columnId: '@timestamp',
sortDirection: Direction.desc,
},
];
const startDate = '2018-03-23T18:49:23.132Z';
const endDate = '2018-03-24T03:33:52.253Z';
@ -159,10 +161,12 @@ describe('epicLocalStorage', () => {
store.dispatch(
updateSort({
id: 'test',
sort: {
columnId: 'event.severity',
sortDirection: Direction.desc,
},
sort: [
{
columnId: 'event.severity',
sortDirection: Direction.desc,
},
],
})
);
await waitFor(() => expect(addTimelineInStorageMock).toHaveBeenCalled());

View file

@ -179,7 +179,7 @@ interface AddNewTimelineParams {
filterQuery: SerializedFilterQuery | null;
};
show?: boolean;
sort?: Sort;
sort?: Sort[];
showCheckboxes?: boolean;
timelineById: TimelineById;
timelineType: TimelineTypeLiteral;
@ -762,7 +762,7 @@ export const updateTimelineRange = ({
interface UpdateTimelineSortParams {
id: string;
sort: Sort;
sort: Sort[];
timelineById: TimelineById;
}

View file

@ -124,7 +124,7 @@ export interface TimelineModel {
/** When true, shows checkboxes enabling selection. Selected events store in selectedEventIds **/
showCheckboxes: boolean;
/** Specifies which column the timeline is sorted on, and the direction (ascending / descending) */
sort: Sort;
sort: Sort[];
/** status: active | draft */
status: TimelineStatus;
/** updated saved object timestamp */

View file

@ -101,10 +101,12 @@ const basicTimeline: TimelineModel = {
selectedEventIds: {},
show: true,
showCheckboxes: false,
sort: {
columnId: '@timestamp',
sortDirection: Direction.desc,
},
sort: [
{
columnId: '@timestamp',
sortDirection: Direction.desc,
},
],
status: TimelineStatus.active,
templateTimelineId: null,
templateTimelineVersion: null,
@ -953,10 +955,12 @@ describe('Timeline', () => {
beforeAll(() => {
update = updateTimelineSort({
id: 'foo',
sort: {
columnId: 'some column',
sortDirection: Direction.desc,
},
sort: [
{
columnId: 'some column',
sortDirection: Direction.desc,
},
],
timelineById: timelineByIdMock,
});
});
@ -964,8 +968,8 @@ describe('Timeline', () => {
expect(update).not.toBe(timelineByIdMock);
});
test('should update the timeline range', () => {
expect(update.foo.sort).toEqual({ columnId: 'some column', sortDirection: Direction.desc });
test('should update the sort attribute', () => {
expect(update.foo.sort).toEqual([{ columnId: 'some column', sortDirection: Direction.desc }]);
});
});

View file

@ -174,7 +174,7 @@ export const timelineSchema = gql`
timelineType: TimelineType
dateRange: DateRangePickerInput
savedQueryId: String
sort: SortTimelineInput
sort: [SortTimelineInput!]
status: TimelineStatus
}
@ -238,10 +238,6 @@ export const timelineSchema = gql`
${favoriteTimeline}
}
type SortTimelineResult {
${sortTimeline}
}
type FilterMetaTimelineResult {
${filtersMetaTimeline}
}
@ -277,7 +273,7 @@ export const timelineSchema = gql`
pinnedEventsSaveObject: [PinnedEvent!]
savedQueryId: String
savedObjectId: String!
sort: SortTimelineResult
sort: ToAny
status: TimelineStatus
title: String
templateTimelineId: String

View file

@ -105,7 +105,7 @@ export interface TimelineInput {
savedQueryId?: Maybe<string>;
sort?: Maybe<SortTimelineInput>;
sort?: Maybe<SortTimelineInput[]>;
status?: Maybe<TimelineStatus>;
}
@ -634,7 +634,7 @@ export interface TimelineResult {
savedObjectId: string;
sort?: Maybe<SortTimelineResult>;
sort?: Maybe<ToAny>;
status?: Maybe<TimelineStatus>;
@ -777,12 +777,6 @@ export interface KueryFilterQueryResult {
expression?: Maybe<string>;
}
export interface SortTimelineResult {
columnId?: Maybe<string>;
sortDirection?: Maybe<string>;
}
export interface ResponseTimelines {
timeline: (Maybe<TimelineResult>)[];
@ -2336,7 +2330,6 @@ export namespace AgentFieldsResolvers {
> = Resolver<R, Parent, TContext>;
}
export namespace CloudFieldsResolvers {
export interface Resolvers<TContext = SiemContext, TypeParent = CloudFields> {
instance?: InstanceResolver<Maybe<CloudInstance>, TypeParent, TContext>;
@ -2665,7 +2658,7 @@ export namespace TimelineResultResolvers {
savedObjectId?: SavedObjectIdResolver<string, TypeParent, TContext>;
sort?: SortResolver<Maybe<SortTimelineResult>, TypeParent, TContext>;
sort?: SortResolver<Maybe<ToAny>, TypeParent, TContext>;
status?: StatusResolver<Maybe<TimelineStatus>, TypeParent, TContext>;
@ -2785,7 +2778,7 @@ export namespace TimelineResultResolvers {
TContext = SiemContext
> = Resolver<R, Parent, TContext>;
export type SortResolver<
R = Maybe<SortTimelineResult>,
R = Maybe<ToAny>,
Parent = TimelineResult,
TContext = SiemContext
> = Resolver<R, Parent, TContext>;
@ -3245,25 +3238,6 @@ export namespace KueryFilterQueryResultResolvers {
> = Resolver<R, Parent, TContext>;
}
export namespace SortTimelineResultResolvers {
export interface Resolvers<TContext = SiemContext, TypeParent = SortTimelineResult> {
columnId?: ColumnIdResolver<Maybe<string>, TypeParent, TContext>;
sortDirection?: SortDirectionResolver<Maybe<string>, TypeParent, TContext>;
}
export type ColumnIdResolver<
R = Maybe<string>,
Parent = SortTimelineResult,
TContext = SiemContext
> = Resolver<R, Parent, TContext>;
export type SortDirectionResolver<
R = Maybe<string>,
Parent = SortTimelineResult,
TContext = SiemContext
> = Resolver<R, Parent, TContext>;
}
export namespace ResponseTimelinesResolvers {
export interface Resolvers<TContext = SiemContext, TypeParent = ResponseTimelines> {
timeline?: TimelineResolver<(Maybe<TimelineResult>)[], TypeParent, TContext>;
@ -6091,7 +6065,6 @@ export type IResolvers<TContext = SiemContext> = {
SerializedFilterQueryResult?: SerializedFilterQueryResultResolvers.Resolvers<TContext>;
SerializedKueryQueryResult?: SerializedKueryQueryResultResolvers.Resolvers<TContext>;
KueryFilterQueryResult?: KueryFilterQueryResultResolvers.Resolvers<TContext>;
SortTimelineResult?: SortTimelineResultResolvers.Resolvers<TContext>;
ResponseTimelines?: ResponseTimelinesResolvers.Resolvers<TContext>;
Mutation?: MutationResolvers.Resolvers<TContext>;
ResponseNote?: ResponseNoteResolvers.Resolvers<TContext>;

View file

@ -60,6 +60,12 @@ export const convertSavedObjectToSavedTimeline = (savedObject: unknown): Timelin
savedTimeline.attributes.timelineType,
savedTimeline.attributes.status
),
sort:
savedTimeline.attributes.sort != null
? Array.isArray(savedTimeline.attributes.sort)
? savedTimeline.attributes.sort
: [savedTimeline.attributes.sort]
: [],
};
return {
savedObjectId: savedTimeline.id,

View file

@ -7,5 +7,37 @@
export const toArray = <T = string>(value: T | T[] | null): T[] =>
Array.isArray(value) ? value : value == null ? [] : [value];
export const toStringArray = <T = string>(value: T | T[] | null): T[] | string[] =>
Array.isArray(value) ? value : value == null ? [] : [`${value}`];
export const toStringArray = <T = string>(value: T | T[] | null): string[] => {
if (Array.isArray(value)) {
return value.reduce<string[]>((acc, v) => {
if (v != null) {
switch (typeof v) {
case 'number':
case 'boolean':
return [...acc, v.toString()];
case 'object':
try {
return [...acc, JSON.stringify(value)];
} catch {
return [...acc, 'Invalid Object'];
}
case 'string':
return [...acc, v];
default:
return [...acc, `${v}`];
}
}
return acc;
}, []);
} else if (value == null) {
return [];
} else if (!Array.isArray(value) && typeof value === 'object') {
try {
return [JSON.stringify(value)];
} catch {
return ['Invalid Object'];
}
} else {
return [`${value}`];
}
};

View file

@ -0,0 +1,123 @@
/*
* 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 { EventHit } from '../../../../../../common/search_strategy';
import { TIMELINE_EVENTS_FIELDS } from './constants';
import { formatTimelineData } from './helpers';
describe('#formatTimelineData', () => {
it('happy path', () => {
const response: EventHit = {
_index: 'auditbeat-7.8.0-2020.11.05-000003',
_id: 'tkCt1nUBaEgqnrVSZ8R_',
_score: 0,
_type: '',
fields: {
'event.category': ['process'],
'process.ppid': [3977],
'user.name': ['jenkins'],
'process.args': ['go', 'vet', './...'],
message: ['Process go (PID: 4313) by user jenkins STARTED'],
'process.pid': [4313],
'process.working_directory': [
'/var/lib/jenkins/workspace/Beats_beats_PR-22624/src/github.com/elastic/beats/libbeat',
],
'process.entity_id': ['Z59cIkAAIw8ZoK0H'],
'host.ip': ['10.224.1.237', 'fe80::4001:aff:fee0:1ed', '172.17.0.1'],
'process.name': ['go'],
'event.action': ['process_started'],
'agent.type': ['auditbeat'],
'@timestamp': ['2020-11-17T14:48:08.922Z'],
'event.module': ['system'],
'event.type': ['start'],
'host.name': ['beats-ci-immutable-ubuntu-1804-1605624279743236239'],
'process.hash.sha1': ['1eac22336a41e0660fb302add9d97daa2bcc7040'],
'host.os.family': ['debian'],
'event.kind': ['event'],
'host.id': ['e59991e835905c65ed3e455b33e13bd6'],
'event.dataset': ['process'],
'process.executable': [
'/var/lib/jenkins/workspace/Beats_beats_PR-22624/.gvm/versions/go1.14.7.linux.amd64/bin/go',
],
},
_source: {},
sort: ['1605624488922', 'beats-ci-immutable-ubuntu-1804-1605624279743236239'],
aggregations: {},
};
expect(
formatTimelineData(
['@timestamp', 'host.name', 'destination.ip', 'source.ip'],
TIMELINE_EVENTS_FIELDS,
response
)
).toEqual({
cursor: {
tiebreaker: 'beats-ci-immutable-ubuntu-1804-1605624279743236239',
value: '1605624488922',
},
node: {
_id: 'tkCt1nUBaEgqnrVSZ8R_',
_index: 'auditbeat-7.8.0-2020.11.05-000003',
data: [
{
field: '@timestamp',
value: ['2020-11-17T14:48:08.922Z'],
},
{
field: 'host.name',
value: ['beats-ci-immutable-ubuntu-1804-1605624279743236239'],
},
],
ecs: {
'@timestamp': ['2020-11-17T14:48:08.922Z'],
_id: 'tkCt1nUBaEgqnrVSZ8R_',
_index: 'auditbeat-7.8.0-2020.11.05-000003',
agent: {
type: ['auditbeat'],
},
event: {
action: ['process_started'],
category: ['process'],
dataset: ['process'],
kind: ['event'],
module: ['system'],
type: ['start'],
},
host: {
id: ['e59991e835905c65ed3e455b33e13bd6'],
ip: ['10.224.1.237', 'fe80::4001:aff:fee0:1ed', '172.17.0.1'],
name: ['beats-ci-immutable-ubuntu-1804-1605624279743236239'],
os: {
family: ['debian'],
},
},
message: ['Process go (PID: 4313) by user jenkins STARTED'],
process: {
args: ['go', 'vet', './...'],
entity_id: ['Z59cIkAAIw8ZoK0H'],
executable: [
'/var/lib/jenkins/workspace/Beats_beats_PR-22624/.gvm/versions/go1.14.7.linux.amd64/bin/go',
],
hash: {
sha1: ['1eac22336a41e0660fb302add9d97daa2bcc7040'],
},
name: ['go'],
pid: ['4313'],
ppid: ['3977'],
working_directory: [
'/var/lib/jenkins/workspace/Beats_beats_PR-22624/src/github.com/elastic/beats/libbeat',
],
},
timestamp: '2020-11-17T14:48:08.922Z',
user: {
name: ['jenkins'],
},
},
},
});
});
});

View file

@ -7,6 +7,7 @@
import { get, has, merge, uniq } from 'lodash/fp';
import { EventHit, TimelineEdges } from '../../../../../../common/search_strategy';
import { toStringArray } from '../../../../helpers/to_array';
import { formatGeoLocation, isGeoField } from '../details/helpers';
export const formatTimelineData = (
dataFields: readonly string[],
@ -18,7 +19,7 @@ export const formatTimelineData = (
flattenedFields.node._id = hit._id;
flattenedFields.node._index = hit._index;
flattenedFields.node.ecs._id = hit._id;
flattenedFields.node.ecs.timestamp = hit._source['@timestamp'];
flattenedFields.node.ecs.timestamp = (hit.fields['@timestamp'][0] ?? '') as string;
flattenedFields.node.ecs._index = hit._index;
if (hit.sort && hit.sort.length > 1) {
flattenedFields.cursor.value = hit.sort[0];
@ -40,13 +41,12 @@ const specialFields = ['_id', '_index', '_type', '_score'];
const mergeTimelineFieldsWithHit = <T>(
fieldName: string,
flattenedFields: T,
hit: { _source: {} },
hit: { fields: Record<string, unknown[]> },
dataFields: readonly string[],
ecsFields: readonly string[]
) => {
if (fieldName != null || dataFields.includes(fieldName)) {
const esField = fieldName;
if (has(esField, hit._source) || specialFields.includes(esField)) {
if (has(fieldName, hit.fields) || specialFields.includes(fieldName)) {
const objectWithProperty = {
node: {
...get('node', flattenedFields),
@ -55,9 +55,11 @@ const mergeTimelineFieldsWithHit = <T>(
...get('node.data', flattenedFields),
{
field: fieldName,
value: specialFields.includes(esField)
? toStringArray(get(esField, hit))
: toStringArray(get(esField, hit._source)),
value: specialFields.includes(fieldName)
? toStringArray(get(fieldName, hit))
: isGeoField(fieldName)
? formatGeoLocation(hit.fields[fieldName])
: toStringArray(hit.fields[fieldName]),
},
]
: get('node.data', flattenedFields),
@ -68,7 +70,7 @@ const mergeTimelineFieldsWithHit = <T>(
...fieldName.split('.').reduceRight(
// @ts-expect-error
(obj, next) => ({ [next]: obj }),
toStringArray<string>(get(esField, hit._source))
toStringArray(hit.fields[fieldName])
),
}
: get('node.ecs', flattenedFields),

View file

@ -9,11 +9,12 @@ import { cloneDeep, uniq } from 'lodash/fp';
import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants';
import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common';
import {
EventHit,
TimelineEventsQueries,
TimelineEventsAllStrategyResponse,
TimelineEventsAllRequestOptions,
TimelineEdges,
} from '../../../../../../common/search_strategy/timeline';
} from '../../../../../../common/search_strategy';
import { inspectStringifyObject } from '../../../../../utils/build_query';
import { SecuritySolutionTimelineFactory } from '../../types';
import { buildTimelineEventsAllQuery } from './query.events_all.dsl';
@ -39,8 +40,7 @@ export const timelineEventsAll: SecuritySolutionTimelineFactory<TimelineEventsQu
const totalCount = response.rawResponse.hits.total || 0;
const hits = response.rawResponse.hits.hits;
const edges: TimelineEdges[] = hits.map((hit) =>
// @ts-expect-error
formatTimelineData(options.fieldRequested, TIMELINE_EVENTS_FIELDS, hit)
formatTimelineData(options.fieldRequested, TIMELINE_EVENTS_FIELDS, hit as EventHit)
);
const inspect = {
dsl: [inspectStringifyObject(buildTimelineEventsAllQuery(queryOptions))],

View file

@ -44,14 +44,11 @@ export const buildTimelineEventsAllQuery = ({
const filter = [...filterClause, ...getTimerangeFilter(timerange), { match_all: {} }];
const getSortField = (sortField: SortField) => {
if (sortField.field) {
const field: string = sortField.field === 'timestamp' ? '@timestamp' : sortField.field;
return [{ [field]: sortField.direction }];
}
return [];
};
const getSortField = (sortFields: SortField[]) =>
sortFields.map((item) => {
const field: string = item.field === 'timestamp' ? '@timestamp' : item.field;
return { [field]: item.direction };
});
const dslQuery = {
allowNoIndices: true,
@ -68,7 +65,7 @@ export const buildTimelineEventsAllQuery = ({
size: querySize,
track_total_hits: true,
sort: getSortField(sort),
_source: fields,
fields,
},
};

View file

@ -0,0 +1,153 @@
/*
* 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 { EventHit } from '../../../../../../common/search_strategy';
import { getDataFromHits } from './helpers';
describe('#getDataFromHits', () => {
it('happy path', () => {
const response: EventHit = {
_index: 'auditbeat-7.8.0-2020.11.05-000003',
_id: 'tkCt1nUBaEgqnrVSZ8R_',
_score: 0,
_type: '',
fields: {
'event.category': ['process'],
'process.ppid': [3977],
'user.name': ['jenkins'],
'process.args': ['go', 'vet', './...'],
message: ['Process go (PID: 4313) by user jenkins STARTED'],
'process.pid': [4313],
'process.working_directory': [
'/var/lib/jenkins/workspace/Beats_beats_PR-22624/src/github.com/elastic/beats/libbeat',
],
'process.entity_id': ['Z59cIkAAIw8ZoK0H'],
'host.ip': ['10.224.1.237', 'fe80::4001:aff:fee0:1ed', '172.17.0.1'],
'process.name': ['go'],
'event.action': ['process_started'],
'agent.type': ['auditbeat'],
'@timestamp': ['2020-11-17T14:48:08.922Z'],
'event.module': ['system'],
'event.type': ['start'],
'host.name': ['beats-ci-immutable-ubuntu-1804-1605624279743236239'],
'process.hash.sha1': ['1eac22336a41e0660fb302add9d97daa2bcc7040'],
'host.os.family': ['debian'],
'event.kind': ['event'],
'host.id': ['e59991e835905c65ed3e455b33e13bd6'],
'event.dataset': ['process'],
'process.executable': [
'/var/lib/jenkins/workspace/Beats_beats_PR-22624/.gvm/versions/go1.14.7.linux.amd64/bin/go',
],
},
_source: {},
sort: ['1605624488922', 'beats-ci-immutable-ubuntu-1804-1605624279743236239'],
aggregations: {},
};
expect(getDataFromHits(response.fields)).toEqual([
{
category: 'event',
field: 'event.category',
originalValue: ['process'],
values: ['process'],
},
{ category: 'process', field: 'process.ppid', originalValue: ['3977'], values: ['3977'] },
{ category: 'user', field: 'user.name', originalValue: ['jenkins'], values: ['jenkins'] },
{
category: 'process',
field: 'process.args',
originalValue: ['go', 'vet', './...'],
values: ['go', 'vet', './...'],
},
{
category: 'base',
field: 'message',
originalValue: ['Process go (PID: 4313) by user jenkins STARTED'],
values: ['Process go (PID: 4313) by user jenkins STARTED'],
},
{ category: 'process', field: 'process.pid', originalValue: ['4313'], values: ['4313'] },
{
category: 'process',
field: 'process.working_directory',
originalValue: [
'/var/lib/jenkins/workspace/Beats_beats_PR-22624/src/github.com/elastic/beats/libbeat',
],
values: [
'/var/lib/jenkins/workspace/Beats_beats_PR-22624/src/github.com/elastic/beats/libbeat',
],
},
{
category: 'process',
field: 'process.entity_id',
originalValue: ['Z59cIkAAIw8ZoK0H'],
values: ['Z59cIkAAIw8ZoK0H'],
},
{
category: 'host',
field: 'host.ip',
originalValue: ['10.224.1.237', 'fe80::4001:aff:fee0:1ed', '172.17.0.1'],
values: ['10.224.1.237', 'fe80::4001:aff:fee0:1ed', '172.17.0.1'],
},
{ category: 'process', field: 'process.name', originalValue: ['go'], values: ['go'] },
{
category: 'event',
field: 'event.action',
originalValue: ['process_started'],
values: ['process_started'],
},
{
category: 'agent',
field: 'agent.type',
originalValue: ['auditbeat'],
values: ['auditbeat'],
},
{
category: 'base',
field: '@timestamp',
originalValue: ['2020-11-17T14:48:08.922Z'],
values: ['2020-11-17T14:48:08.922Z'],
},
{ category: 'event', field: 'event.module', originalValue: ['system'], values: ['system'] },
{ category: 'event', field: 'event.type', originalValue: ['start'], values: ['start'] },
{
category: 'host',
field: 'host.name',
originalValue: ['beats-ci-immutable-ubuntu-1804-1605624279743236239'],
values: ['beats-ci-immutable-ubuntu-1804-1605624279743236239'],
},
{
category: 'process',
field: 'process.hash.sha1',
originalValue: ['1eac22336a41e0660fb302add9d97daa2bcc7040'],
values: ['1eac22336a41e0660fb302add9d97daa2bcc7040'],
},
{ category: 'host', field: 'host.os.family', originalValue: ['debian'], values: ['debian'] },
{ category: 'event', field: 'event.kind', originalValue: ['event'], values: ['event'] },
{
category: 'host',
field: 'host.id',
originalValue: ['e59991e835905c65ed3e455b33e13bd6'],
values: ['e59991e835905c65ed3e455b33e13bd6'],
},
{
category: 'event',
field: 'event.dataset',
originalValue: ['process'],
values: ['process'],
},
{
category: 'process',
field: 'process.executable',
originalValue: [
'/var/lib/jenkins/workspace/Beats_beats_PR-22624/.gvm/versions/go1.14.7.linux.amd64/bin/go',
],
values: [
'/var/lib/jenkins/workspace/Beats_beats_PR-22624/.gvm/versions/go1.14.7.linux.amd64/bin/go',
],
},
]);
});
});

View file

@ -4,9 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { get, isEmpty, isNumber, isObject, isString } from 'lodash/fp';
import { isEmpty } from 'lodash/fp';
import { TimelineEventsDetailsItem } from '../../../../../../common/search_strategy/timeline';
import { toStringArray } from '../../../../helpers/to_array';
export const baseCategoryFields = ['@timestamp', 'labels', 'message', 'tags'];
@ -18,39 +19,32 @@ export const getFieldCategory = (field: string): string => {
return fieldCategory;
};
export const getDataFromHits = (
sources: EventSource,
category?: string,
path?: string
): TimelineEventsDetailsItem[] =>
Object.keys(sources).reduce<TimelineEventsDetailsItem[]>((accumulator, source) => {
const item: EventSource = get(source, sources);
if (Array.isArray(item) || isString(item) || isNumber(item)) {
const field = path ? `${path}.${source}` : source;
const fieldCategory = getFieldCategory(field);
return [
...accumulator,
{
category: fieldCategory,
field,
values: Array.isArray(item)
? item.map((value) => {
if (isObject(value)) {
return JSON.stringify(value);
}
return value;
})
: [item],
originalValue: item,
} as TimelineEventsDetailsItem,
];
} else if (isObject(item)) {
return [
...accumulator,
...getDataFromHits(item, category || source, path ? `${path}.${source}` : source),
];
export const formatGeoLocation = (item: unknown[]) => {
const itemGeo = item.length > 0 ? (item[0] as { coordinates: number[] }) : null;
if (itemGeo != null && !isEmpty(itemGeo.coordinates)) {
try {
return toStringArray({ long: itemGeo.coordinates[0], lat: itemGeo.coordinates[1] });
} catch {
return toStringArray(item);
}
return accumulator;
}
return toStringArray(item);
};
export const isGeoField = (field: string) =>
field.includes('geo.location') || field.includes('geoip.location');
export const getDataFromHits = (fields: Record<string, unknown[]>): TimelineEventsDetailsItem[] =>
Object.keys(fields).reduce<TimelineEventsDetailsItem[]>((accumulator, field) => {
const item: unknown[] = fields[field];
const fieldCategory = getFieldCategory(field);
return [
...accumulator,
{
category: fieldCategory,
field,
values: isGeoField(field) ? formatGeoLocation(item) : toStringArray(item),
originalValue: toStringArray(item),
} as TimelineEventsDetailsItem,
];
}, []);

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { getOr, merge } from 'lodash/fp';
import { cloneDeep, merge } from 'lodash/fp';
import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common';
import {
@ -27,13 +27,14 @@ export const timelineEventsDetails: SecuritySolutionTimelineFactory<TimelineEven
response: IEsSearchResponse<unknown>
): Promise<TimelineEventsDetailsStrategyResponse> => {
const { indexName, eventId, docValueFields = [] } = options;
const sourceData = getOr({}, 'hits.hits.0._source', response.rawResponse);
const hitsData = getOr({}, 'hits.hits.0', response.rawResponse);
const fieldsData = cloneDeep(response.rawResponse.hits.hits[0].fields ?? {});
const hitsData = cloneDeep(response.rawResponse.hits.hits[0] ?? {});
delete hitsData._source;
delete hitsData.fields;
const inspect = {
dsl: [inspectStringifyObject(buildTimelineDetailsQuery(indexName, eventId, docValueFields))],
};
const data = getDataFromHits(merge(sourceData, hitsData));
const data = getDataFromHits(merge(fieldsData, hitsData));
return {
...response,

View file

@ -21,6 +21,7 @@ export const buildTimelineDetailsQuery = (
_id: [id],
},
},
fields: ['*'],
},
size: 1,
});

View file

@ -171,7 +171,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(kqlMode).to.be(timelineObject.kqlMode);
expect(kqlQuery).to.eql(timelineObject.kqlQuery);
expect(savedObjectId).to.not.be.empty();
expect(sort).to.eql(timelineObject.sort);
expect(sort).to.eql([timelineObject.sort]);
expect(title).to.be(timelineObject.title);
expect(version).to.not.be.empty();
});

View file

@ -14,228 +14,211 @@ import { FtrProviderContext } from '../../ftr_provider_context';
const INDEX_NAME = 'filebeat-7.0.0-iot-2019.06';
const ID = 'QRhG1WgBqd-n62SwZYDT';
const EXPECTED_DATA = [
{
category: 'base',
field: '@timestamp',
values: ['2019-02-10T02:39:44.107Z'],
originalValue: '2019-02-10T02:39:44.107Z',
},
{ category: '@version', field: '@version', values: ['1'], originalValue: '1' },
{
category: 'agent',
field: 'agent.ephemeral_id',
values: ['909cd6a1-527d-41a5-9585-a7fb5386f851'],
originalValue: '909cd6a1-527d-41a5-9585-a7fb5386f851',
},
{
category: 'agent',
field: 'agent.hostname',
values: ['raspberrypi'],
originalValue: 'raspberrypi',
},
{
category: 'agent',
field: 'agent.id',
values: ['4d3ea604-27e5-4ec7-ab64-44f82285d776'],
originalValue: '4d3ea604-27e5-4ec7-ab64-44f82285d776',
},
{ category: 'agent', field: 'agent.type', values: ['filebeat'], originalValue: 'filebeat' },
{ category: 'agent', field: 'agent.version', values: ['7.0.0'], originalValue: '7.0.0' },
{
category: 'destination',
field: 'destination.domain',
values: ['s3-iad-2.cf.dash.row.aiv-cdn.net'],
originalValue: 's3-iad-2.cf.dash.row.aiv-cdn.net',
},
{
category: 'destination',
field: 'destination.ip',
values: ['10.100.7.196'],
originalValue: '10.100.7.196',
},
{ category: 'destination', field: 'destination.port', values: [40684], originalValue: 40684 },
{ category: 'ecs', field: 'ecs.version', values: ['1.0.0-beta2'], originalValue: '1.0.0-beta2' },
{
category: 'event',
field: 'event.dataset',
values: ['suricata.eve'],
originalValue: 'suricata.eve',
},
{
category: 'event',
field: 'event.end',
values: ['2019-02-10T02:39:44.107Z'],
originalValue: '2019-02-10T02:39:44.107Z',
},
{ category: 'event', field: 'event.kind', values: ['event'], originalValue: 'event' },
{ category: 'event', field: 'event.module', values: ['suricata'], originalValue: 'suricata' },
{ category: 'event', field: 'event.type', values: ['fileinfo'], originalValue: 'fileinfo' },
{
category: 'file',
field: 'file.path',
values: [
'/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4',
],
originalValue:
originalValue: [
'/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4',
],
},
{
category: 'traefik',
field: 'traefik.access.geoip.region_iso_code',
values: ['US-WA'],
originalValue: ['US-WA'],
},
{ category: 'file', field: 'file.size', values: [48277], originalValue: 48277 },
{ category: 'fileset', field: 'fileset.name', values: ['eve'], originalValue: 'eve' },
{ category: 'flow', field: 'flow.locality', values: ['public'], originalValue: 'public' },
{ category: 'host', field: 'host.architecture', values: ['armv7l'], originalValue: 'armv7l' },
{
category: 'host',
field: 'host.hostname',
values: ['raspberrypi'],
originalValue: 'raspberrypi',
originalValue: ['raspberrypi'],
},
{
category: 'host',
field: 'host.id',
values: ['b19a781f683541a7a25ee345133aa399'],
originalValue: 'b19a781f683541a7a25ee345133aa399',
},
{ category: 'host', field: 'host.name', values: ['raspberrypi'], originalValue: 'raspberrypi' },
{ category: 'host', field: 'host.os.codename', values: ['stretch'], originalValue: 'stretch' },
{ category: 'host', field: 'host.os.family', values: [''], originalValue: '' },
{
category: 'host',
field: 'host.os.kernel',
values: ['4.14.50-v7+'],
originalValue: '4.14.50-v7+',
category: 'traefik',
field: 'traefik.access.geoip.location',
values: ['{"long":-122.3341,"lat":47.6103}'],
originalValue: ['[{"coordinates":[-122.3341,47.6103],"type":"Point"}]'],
},
{
category: 'host',
field: 'host.os.name',
values: ['Raspbian GNU/Linux'],
originalValue: 'Raspbian GNU/Linux',
category: 'suricata',
field: 'suricata.eve.src_port',
values: ['80'],
originalValue: ['80'],
},
{
category: 'traefik',
field: 'traefik.access.geoip.city_name',
values: ['Seattle'],
originalValue: ['Seattle'],
},
{
category: 'service',
field: 'service.type',
values: ['suricata'],
originalValue: ['suricata'],
},
{
category: 'http',
field: 'http.request.method',
values: ['get'],
originalValue: ['get'],
},
{ category: 'host', field: 'host.os.platform', values: ['raspbian'], originalValue: 'raspbian' },
{
category: 'host',
field: 'host.os.version',
values: ['9 (stretch)'],
originalValue: '9 (stretch)',
},
{ category: 'http', field: 'http.request.method', values: ['get'], originalValue: 'get' },
{ category: 'http', field: 'http.response.body.bytes', values: [48277], originalValue: 48277 },
{ category: 'http', field: 'http.response.status_code', values: [206], originalValue: 206 },
{ category: 'input', field: 'input.type', values: ['log'], originalValue: 'log' },
{
category: 'base',
field: 'labels.pipeline',
values: ['filebeat-7.0.0-suricata-eve-pipeline'],
originalValue: 'filebeat-7.0.0-suricata-eve-pipeline',
},
{
category: 'log',
field: 'log.file.path',
values: ['/var/log/suricata/eve.json'],
originalValue: '/var/log/suricata/eve.json',
},
{ category: 'log', field: 'log.offset', values: [1856288115], originalValue: 1856288115 },
{ category: 'network', field: 'network.name', values: ['iot'], originalValue: 'iot' },
{ category: 'network', field: 'network.protocol', values: ['http'], originalValue: 'http' },
{ category: 'network', field: 'network.transport', values: ['tcp'], originalValue: 'tcp' },
{ category: 'service', field: 'service.type', values: ['suricata'], originalValue: 'suricata' },
{ category: 'source', field: 'source.as.num', values: [16509], originalValue: 16509 },
{
category: 'source',
field: 'source.as.org',
values: ['Amazon.com, Inc.'],
originalValue: 'Amazon.com, Inc.',
},
{
category: 'source',
field: 'source.domain',
values: ['server-54-239-219-210.jfk51.r.cloudfront.net'],
originalValue: 'server-54-239-219-210.jfk51.r.cloudfront.net',
},
{
category: 'source',
field: 'source.geo.city_name',
values: ['Seattle'],
originalValue: 'Seattle',
},
{
category: 'source',
field: 'source.geo.continent_name',
values: ['North America'],
originalValue: 'North America',
},
{ category: 'source', field: 'source.geo.country_iso_code', values: ['US'], originalValue: 'US' },
{
category: 'source',
field: 'source.geo.location.lat',
values: [47.6103],
originalValue: 47.6103,
},
{
category: 'source',
field: 'source.geo.location.lon',
values: [-122.3341],
originalValue: -122.3341,
},
{
category: 'source',
field: 'source.geo.region_iso_code',
values: ['US-WA'],
originalValue: 'US-WA',
originalValue: ['9 (stretch)'],
},
{
category: 'source',
field: 'source.geo.region_name',
values: ['Washington'],
originalValue: 'Washington',
},
{
category: 'source',
field: 'source.ip',
values: ['54.239.219.210'],
originalValue: '54.239.219.210',
},
{ category: 'source', field: 'source.port', values: [80], originalValue: 80 },
{
category: 'suricata',
field: 'suricata.eve.fileinfo.state',
values: ['CLOSED'],
originalValue: 'CLOSED',
},
{ category: 'suricata', field: 'suricata.eve.fileinfo.tx_id', values: [301], originalValue: 301 },
{
category: 'suricata',
field: 'suricata.eve.flow_id',
values: [196625917175466],
originalValue: 196625917175466,
},
{
category: 'suricata',
field: 'suricata.eve.http.http_content_type',
values: ['video/mp4'],
originalValue: 'video/mp4',
originalValue: ['Washington'],
},
{
category: 'suricata',
field: 'suricata.eve.http.protocol',
values: ['HTTP/1.1'],
originalValue: 'HTTP/1.1',
originalValue: ['HTTP/1.1'],
},
{ category: 'suricata', field: 'suricata.eve.in_iface', values: ['eth0'], originalValue: 'eth0' },
{ category: 'base', field: 'tags', values: ['suricata'], originalValue: ['suricata'] },
{
category: 'url',
field: 'url.domain',
category: 'host',
field: 'host.os.name',
values: ['Raspbian GNU/Linux'],
originalValue: ['Raspbian GNU/Linux'],
},
{
category: 'source',
field: 'source.ip',
values: ['54.239.219.210'],
originalValue: ['54.239.219.210'],
},
{
category: 'host',
field: 'host.name',
values: ['raspberrypi'],
originalValue: ['raspberrypi'],
},
{
category: 'source',
field: 'source.geo.region_iso_code',
values: ['US-WA'],
originalValue: ['US-WA'],
},
{
category: 'http',
field: 'http.response.status_code',
values: ['206'],
originalValue: ['206'],
},
{
category: 'event',
field: 'event.kind',
values: ['event'],
originalValue: ['event'],
},
{
category: 'suricata',
field: 'suricata.eve.flow_id',
values: ['196625917175466'],
originalValue: ['196625917175466'],
},
{
category: 'source',
field: 'source.geo.city_name',
values: ['Seattle'],
originalValue: ['Seattle'],
},
{
category: 'suricata',
field: 'suricata.eve.proto',
values: ['tcp'],
originalValue: ['tcp'],
},
{
category: 'flow',
field: 'flow.locality',
values: ['public'],
originalValue: ['public'],
},
{
category: 'traefik',
field: 'traefik.access.geoip.country_iso_code',
values: ['US'],
originalValue: ['US'],
},
{
category: 'fileset',
field: 'fileset.name',
values: ['eve'],
originalValue: ['eve'],
},
{
category: 'input',
field: 'input.type',
values: ['log'],
originalValue: ['log'],
},
{
category: 'log',
field: 'log.offset',
values: ['1856288115'],
originalValue: ['1856288115'],
},
{
category: 'destination',
field: 'destination.domain',
values: ['s3-iad-2.cf.dash.row.aiv-cdn.net'],
originalValue: 's3-iad-2.cf.dash.row.aiv-cdn.net',
originalValue: ['s3-iad-2.cf.dash.row.aiv-cdn.net'],
},
{
category: 'url',
field: 'url.original',
category: 'agent',
field: 'agent.hostname',
values: ['raspberrypi'],
originalValue: ['raspberrypi'],
},
{
category: 'suricata',
field: 'suricata.eve.http.hostname',
values: ['s3-iad-2.cf.dash.row.aiv-cdn.net'],
originalValue: ['s3-iad-2.cf.dash.row.aiv-cdn.net'],
},
{
category: 'suricata',
field: 'suricata.eve.in_iface',
values: ['eth0'],
originalValue: ['eth0'],
},
{
category: 'base',
field: 'tags',
values: ['suricata'],
originalValue: ['suricata'],
},
{
category: 'host',
field: 'host.architecture',
values: ['armv7l'],
originalValue: ['armv7l'],
},
{
category: 'suricata',
field: 'suricata.eve.http.status',
values: ['206'],
originalValue: ['206'],
},
{
category: 'suricata',
field: 'suricata.eve.http.url',
values: [
'/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4',
],
originalValue:
originalValue: [
'/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4',
],
},
{
category: 'url',
@ -243,22 +226,336 @@ const EXPECTED_DATA = [
values: [
'/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4',
],
originalValue:
originalValue: [
'/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4',
],
},
{
category: 'source',
field: 'source.port',
values: ['80'],
originalValue: ['80'],
},
{
category: 'agent',
field: 'agent.id',
values: ['4d3ea604-27e5-4ec7-ab64-44f82285d776'],
originalValue: ['4d3ea604-27e5-4ec7-ab64-44f82285d776'],
},
{
category: 'host',
field: 'host.containerized',
values: ['false'],
originalValue: ['false'],
},
{
category: 'ecs',
field: 'ecs.version',
values: ['1.0.0-beta2'],
originalValue: ['1.0.0-beta2'],
},
{
category: 'agent',
field: 'agent.version',
values: ['7.0.0'],
originalValue: ['7.0.0'],
},
{
category: 'suricata',
field: 'suricata.eve.fileinfo.stored',
values: ['false'],
originalValue: ['false'],
},
{
category: 'host',
field: 'host.os.family',
values: [''],
originalValue: [''],
},
{
category: 'base',
field: 'labels.pipeline',
values: ['filebeat-7.0.0-suricata-eve-pipeline'],
originalValue: ['filebeat-7.0.0-suricata-eve-pipeline'],
},
{
category: 'suricata',
field: 'suricata.eve.src_ip',
values: ['54.239.219.210'],
originalValue: ['54.239.219.210'],
},
{
category: 'suricata',
field: 'suricata.eve.fileinfo.state',
values: ['CLOSED'],
originalValue: ['CLOSED'],
},
{
category: 'destination',
field: 'destination.port',
values: ['40684'],
originalValue: ['40684'],
},
{
category: 'traefik',
field: 'traefik.access.geoip.region_name',
values: ['Washington'],
originalValue: ['Washington'],
},
{
category: 'source',
field: 'source.as.num',
values: ['16509'],
originalValue: ['16509'],
},
{
category: 'event',
field: 'event.end',
values: ['2019-02-10T02:39:44.107Z'],
originalValue: ['2019-02-10T02:39:44.107Z'],
},
{
category: 'source',
field: 'source.geo.location',
values: ['{"long":-122.3341,"lat":47.6103}'],
originalValue: ['[{"coordinates":[-122.3341,47.6103],"type":"Point"}]'],
},
{
category: 'source',
field: 'source.domain',
values: ['server-54-239-219-210.jfk51.r.cloudfront.net'],
originalValue: ['server-54-239-219-210.jfk51.r.cloudfront.net'],
},
{
category: 'suricata',
field: 'suricata.eve.fileinfo.size',
values: ['48277'],
originalValue: ['48277'],
},
{
category: 'suricata',
field: 'suricata.eve.app_proto',
values: ['http'],
originalValue: ['http'],
},
{
category: 'agent',
field: 'agent.type',
values: ['filebeat'],
originalValue: ['filebeat'],
},
{
category: 'suricata',
field: 'suricata.eve.fileinfo.tx_id',
values: ['301'],
originalValue: ['301'],
},
{
category: 'event',
field: 'event.module',
values: ['suricata'],
originalValue: ['suricata'],
},
{
category: 'network',
field: 'network.protocol',
values: ['http'],
originalValue: ['http'],
},
{
category: 'host',
field: 'host.os.kernel',
values: ['4.14.50-v7+'],
originalValue: ['4.14.50-v7+'],
},
{
category: 'source',
field: 'source.geo.country_iso_code',
values: ['US'],
originalValue: ['US'],
},
{
category: '@version',
field: '@version',
values: ['1'],
originalValue: ['1'],
},
{
category: 'host',
field: 'host.id',
values: ['b19a781f683541a7a25ee345133aa399'],
originalValue: ['b19a781f683541a7a25ee345133aa399'],
},
{
category: 'source',
field: 'source.as.org',
values: ['Amazon.com, Inc.'],
originalValue: ['Amazon.com, Inc.'],
},
{
category: 'suricata',
field: 'suricata.eve.timestamp',
values: ['2019-02-10T02:39:44.107Z'],
originalValue: ['2019-02-10T02:39:44.107Z'],
},
{
category: 'host',
field: 'host.os.codename',
values: ['stretch'],
originalValue: ['stretch'],
},
{
category: 'source',
field: 'source.geo.continent_name',
values: ['North America'],
originalValue: ['North America'],
},
{
category: 'network',
field: 'network.name',
values: ['iot'],
originalValue: ['iot'],
},
{
category: 'suricata',
field: 'suricata.eve.http.http_method',
values: ['get'],
originalValue: ['get'],
},
{
category: 'traefik',
field: 'traefik.access.geoip.continent_name',
values: ['North America'],
originalValue: ['North America'],
},
{
category: 'file',
field: 'file.size',
values: ['48277'],
originalValue: ['48277'],
},
{
category: 'destination',
field: 'destination.ip',
values: ['10.100.7.196'],
originalValue: ['10.100.7.196'],
},
{
category: 'suricata',
field: 'suricata.eve.http.length',
values: ['48277'],
originalValue: ['48277'],
},
{
category: 'http',
field: 'http.response.body.bytes',
values: ['48277'],
originalValue: ['48277'],
},
{
category: 'suricata',
field: 'suricata.eve.fileinfo.filename',
values: [
'/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4',
],
originalValue: [
'/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4',
],
},
{
category: 'suricata',
field: 'suricata.eve.dest_ip',
values: ['10.100.7.196'],
originalValue: ['10.100.7.196'],
},
{
category: 'network',
field: 'network.transport',
values: ['tcp'],
originalValue: ['tcp'],
},
{
category: 'url',
field: 'url.original',
values: [
'/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4',
],
originalValue: [
'/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4',
],
},
{
category: 'base',
field: '@timestamp',
values: ['2019-02-10T02:39:44.107Z'],
originalValue: ['2019-02-10T02:39:44.107Z'],
},
{
category: 'host',
field: 'host.os.platform',
values: ['raspbian'],
originalValue: ['raspbian'],
},
{
category: 'suricata',
field: 'suricata.eve.dest_port',
values: ['40684'],
originalValue: ['40684'],
},
{
category: 'event',
field: 'event.type',
values: ['fileinfo'],
originalValue: ['fileinfo'],
},
{
category: 'log',
field: 'log.file.path',
values: ['/var/log/suricata/eve.json'],
originalValue: ['/var/log/suricata/eve.json'],
},
{
category: 'url',
field: 'url.domain',
values: ['s3-iad-2.cf.dash.row.aiv-cdn.net'],
originalValue: ['s3-iad-2.cf.dash.row.aiv-cdn.net'],
},
{
category: 'agent',
field: 'agent.ephemeral_id',
values: ['909cd6a1-527d-41a5-9585-a7fb5386f851'],
originalValue: ['909cd6a1-527d-41a5-9585-a7fb5386f851'],
},
{
category: 'suricata',
field: 'suricata.eve.http.http_content_type',
values: ['video/mp4'],
originalValue: ['video/mp4'],
},
{
category: 'event',
field: 'event.dataset',
values: ['suricata.eve'],
originalValue: ['suricata.eve'],
},
{
category: '_index',
field: '_index',
values: ['filebeat-7.0.0-iot-2019.06'],
originalValue: 'filebeat-7.0.0-iot-2019.06',
originalValue: ['filebeat-7.0.0-iot-2019.06'],
},
{
category: '_id',
field: '_id',
values: ['QRhG1WgBqd-n62SwZYDT'],
originalValue: 'QRhG1WgBqd-n62SwZYDT',
originalValue: ['QRhG1WgBqd-n62SwZYDT'],
},
{
category: '_score',
field: '_score',
values: ['1'],
originalValue: ['1'],
},
{ category: '_score', field: '_score', values: [1], originalValue: 1 },
];
export default function ({ getService }: FtrProviderContext) {