diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 3b5b5958c879f..7cd5692176ee3 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -165,13 +165,6 @@ export const showAllOthersBucket: string[] = [ 'user.name', ]; -/** - * CreateTemplateTimelineBtn - * https://github.com/elastic/kibana/pull/66613 - * Remove the comment here to enable template timeline - */ -export const disableTemplate = false; - /* * This should be set to true after https://github.com/elastic/kibana/pull/67496 is merged */ diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index d05c44601e1f2..90d254b15e8b3 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -50,6 +50,16 @@ const SavedDataProviderQueryMatchRuntimeType = runtimeTypes.partial({ queryMatch: unionWithNullType(SavedDataProviderQueryMatchBasicRuntimeType), }); +export enum DataProviderType { + default = 'default', + template = 'template', +} + +export const DataProviderTypeLiteralRt = runtimeTypes.union([ + runtimeTypes.literal(DataProviderType.default), + runtimeTypes.literal(DataProviderType.template), +]); + const SavedDataProviderRuntimeType = runtimeTypes.partial({ id: unionWithNullType(runtimeTypes.string), name: unionWithNullType(runtimeTypes.string), @@ -58,6 +68,7 @@ const SavedDataProviderRuntimeType = runtimeTypes.partial({ kqlQuery: unionWithNullType(runtimeTypes.string), queryMatch: unionWithNullType(SavedDataProviderQueryMatchBasicRuntimeType), and: unionWithNullType(runtimeTypes.array(SavedDataProviderQueryMatchRuntimeType)), + type: unionWithNullType(DataProviderTypeLiteralRt), }); /* @@ -154,7 +165,7 @@ export type TimelineStatusLiteralWithNull = runtimeTypes.TypeOf< >; /** - * Template timeline type + * Timeline template type */ export enum TemplateTimelineType { diff --git a/x-pack/plugins/security_solution/cypress/screens/timeline.ts b/x-pack/plugins/security_solution/cypress/screens/timeline.ts index c673cf34b6dae..14282b84b5ffc 100644 --- a/x-pack/plugins/security_solution/cypress/screens/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/screens/timeline.ts @@ -9,7 +9,7 @@ export const CLOSE_TIMELINE_BTN = '[data-test-subj="close-timeline"]'; export const CREATE_NEW_TIMELINE = '[data-test-subj="timeline-new"]'; export const DRAGGABLE_HEADER = - '[data-test-subj="headers-group"] [data-test-subj="draggable-header"]'; + '[data-test-subj="events-viewer-panel"] [data-test-subj="headers-group"] [data-test-subj="draggable-header"]'; export const HEADERS_GROUP = '[data-test-subj="headers-group"]'; @@ -21,7 +21,8 @@ export const ID_TOGGLE_FIELD = '[data-test-subj="toggle-field-_id"]'; export const PROVIDER_BADGE = '[data-test-subj="providerBadge"]'; -export const REMOVE_COLUMN = '[data-test-subj="remove-column"]'; +export const REMOVE_COLUMN = + '[data-test-subj="events-viewer-panel"] [data-test-subj="remove-column"]'; export const RESET_FIELDS = '[data-test-subj="events-viewer-panel"] [data-test-subj="reset-fields"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts index 761fd2c1e6a0b..37ce9094dc594 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts @@ -27,8 +27,6 @@ import { import { drag, drop } from '../tasks/common'; -export const hostExistsQuery = 'host.name: *'; - export const addDescriptionToTimeline = (description: string) => { cy.get(TIMELINE_DESCRIPTION).type(`${description}{enter}`); cy.get(DATE_PICKER_APPLY_BUTTON_TIMELINE).click().invoke('text').should('not.equal', 'Updating'); @@ -79,7 +77,6 @@ export const openTimelineSettings = () => { }; export const populateTimeline = () => { - executeTimelineKQL(hostExistsQuery); cy.get(SERVER_SIDE_EVENT_COUNT) .invoke('text') .then((strCount) => { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx index bd62b79a3c54e..2fa7cfeedcd15 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx @@ -215,8 +215,8 @@ describe('alert actions', () => { columnId: '@timestamp', sortDirection: 'desc', }, - status: TimelineStatus.active, - title: 'Test rule - Duplicate', + status: TimelineStatus.draft, + title: '', timelineType: TimelineType.default, templateTimelineId: null, templateTimelineVersion: null, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx index ba392e9904cc4..24f292cf9135b 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx @@ -10,7 +10,14 @@ import moment from 'moment'; import { updateAlertStatus } from '../../containers/detection_engine/alerts/api'; import { SendAlertToTimelineActionProps, UpdateAlertStatusActionProps } from './types'; -import { TimelineNonEcsData, GetOneTimeline, TimelineResult, Ecs } from '../../../graphql/types'; +import { + TimelineNonEcsData, + GetOneTimeline, + TimelineResult, + Ecs, + TimelineStatus, + TimelineType, +} from '../../../graphql/types'; import { oneTimelineQuery } from '../../../timelines/containers/one/index.gql_query'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; import { @@ -122,20 +129,31 @@ export const sendAlertToTimelineAction = async ({ if (!isEmpty(resultingTimeline)) { const timelineTemplate: TimelineResult = omitTypenameInTimeline(resultingTimeline); openAlertInBasicTimeline = false; - const { timeline } = formatTimelineResultToModel(timelineTemplate, true); + const { timeline } = formatTimelineResultToModel( + timelineTemplate, + true, + timelineTemplate.timelineType ?? TimelineType.default + ); const query = replaceTemplateFieldFromQuery( timeline.kqlQuery?.filterQuery?.kuery?.expression ?? '', - ecsData + ecsData, + timeline.timelineType ); const filters = replaceTemplateFieldFromMatchFilters(timeline.filters ?? [], ecsData); const dataProviders = replaceTemplateFieldFromDataProviders( timeline.dataProviders ?? [], - ecsData + ecsData, + timeline.timelineType ); + createTimeline({ from, timeline: { ...timeline, + title: '', + timelineType: TimelineType.default, + templateTimelineId: null, + status: TimelineStatus.draft, dataProviders, eventType: 'all', filters, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/helpers.test.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/helpers.test.ts index ad4f5cf8b4aa8..4decddd6b8886 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/helpers.test.ts @@ -5,9 +5,13 @@ */ import { cloneDeep } from 'lodash/fp'; +import { TimelineType } from '../../../../common/types/timeline'; import { mockEcsData } from '../../../common/mock/mock_ecs'; import { Filter } from '../../../../../../../src/plugins/data/public'; -import { DataProvider } from '../../../timelines/components/timeline/data_providers/data_provider'; +import { + DataProvider, + DataProviderType, +} from '../../../timelines/components/timeline/data_providers/data_provider'; import { mockDataProviders } from '../../../timelines/components/timeline/data_providers/mock/mock_data_providers'; import { @@ -95,36 +99,100 @@ describe('helpers', () => { }); describe('replaceTemplateFieldFromQuery', () => { - test('given an empty query string this returns an empty query string', () => { - const replacement = replaceTemplateFieldFromQuery('', mockEcsDataClone[0]); - expect(replacement).toEqual(''); - }); + describe('timelineType default', () => { + test('given an empty query string this returns an empty query string', () => { + const replacement = replaceTemplateFieldFromQuery( + '', + mockEcsDataClone[0], + TimelineType.default + ); + expect(replacement).toEqual(''); + }); - test('given a query string with spaces this returns an empty query string', () => { - const replacement = replaceTemplateFieldFromQuery(' ', mockEcsDataClone[0]); - expect(replacement).toEqual(''); - }); + test('given a query string with spaces this returns an empty query string', () => { + const replacement = replaceTemplateFieldFromQuery( + ' ', + mockEcsDataClone[0], + TimelineType.default + ); + expect(replacement).toEqual(''); + }); - test('it should replace a query with a template value such as apache from a mock template', () => { - const replacement = replaceTemplateFieldFromQuery( - 'host.name: placeholdertext', - mockEcsDataClone[0] - ); - expect(replacement).toEqual('host.name: apache'); - }); + test('it should replace a query with a template value such as apache from a mock template', () => { + const replacement = replaceTemplateFieldFromQuery( + 'host.name: placeholdertext', + mockEcsDataClone[0], + TimelineType.default + ); + expect(replacement).toEqual('host.name: apache'); + }); + + test('it should replace a template field with an ECS value that is not an array', () => { + mockEcsDataClone[0].host!.name = ('apache' as unknown) as string[]; // very unsafe cast for this test case + const replacement = replaceTemplateFieldFromQuery( + 'host.name: *', + mockEcsDataClone[0], + TimelineType.default + ); + expect(replacement).toEqual('host.name: *'); + }); - test('it should replace a template field with an ECS value that is not an array', () => { - mockEcsDataClone[0].host!.name = ('apache' as unknown) as string[]; // very unsafe cast for this test case - const replacement = replaceTemplateFieldFromQuery('host.name: *', mockEcsDataClone[0]); - expect(replacement).toEqual('host.name: *'); + test('it should NOT replace a query with a template value that is not part of the template fields array', () => { + const replacement = replaceTemplateFieldFromQuery( + 'user.id: placeholdertext', + mockEcsDataClone[0], + TimelineType.default + ); + expect(replacement).toEqual('user.id: placeholdertext'); + }); }); - test('it should NOT replace a query with a template value that is not part of the template fields array', () => { - const replacement = replaceTemplateFieldFromQuery( - 'user.id: placeholdertext', - mockEcsDataClone[0] - ); - expect(replacement).toEqual('user.id: placeholdertext'); + describe('timelineType template', () => { + test('given an empty query string this returns an empty query string', () => { + const replacement = replaceTemplateFieldFromQuery( + '', + mockEcsDataClone[0], + TimelineType.template + ); + expect(replacement).toEqual(''); + }); + + test('given a query string with spaces this returns an empty query string', () => { + const replacement = replaceTemplateFieldFromQuery( + ' ', + mockEcsDataClone[0], + TimelineType.template + ); + expect(replacement).toEqual(''); + }); + + test('it should NOT replace a query with a template value such as apache from a mock template', () => { + const replacement = replaceTemplateFieldFromQuery( + 'host.name: placeholdertext', + mockEcsDataClone[0], + TimelineType.template + ); + expect(replacement).toEqual('host.name: placeholdertext'); + }); + + test('it should NOT replace a template field with an ECS value that is not an array', () => { + mockEcsDataClone[0].host!.name = ('apache' as unknown) as string[]; // very unsafe cast for this test case + const replacement = replaceTemplateFieldFromQuery( + 'host.name: *', + mockEcsDataClone[0], + TimelineType.default + ); + expect(replacement).toEqual('host.name: *'); + }); + + test('it should NOT replace a query with a template value that is not part of the template fields array', () => { + const replacement = replaceTemplateFieldFromQuery( + 'user.id: placeholdertext', + mockEcsDataClone[0], + TimelineType.default + ); + expect(replacement).toEqual('user.id: placeholdertext'); + }); }); }); @@ -198,76 +266,216 @@ describe('helpers', () => { }); describe('reformatDataProviderWithNewValue', () => { - test('it should replace a query with a template value such as apache from a mock data provider', () => { - const mockDataProvider: DataProvider = mockDataProviders[0]; - mockDataProvider.queryMatch.field = 'host.name'; - mockDataProvider.id = 'Braden'; - mockDataProvider.name = 'Braden'; - mockDataProvider.queryMatch.value = 'Braden'; - const replacement = reformatDataProviderWithNewValue(mockDataProvider, mockEcsDataClone[0]); - expect(replacement).toEqual({ - id: 'apache', - name: 'apache', - enabled: true, - excluded: false, - kqlQuery: '', - queryMatch: { - field: 'host.name', - value: 'apache', - operator: ':', - displayField: undefined, - displayValue: undefined, - }, - and: [], + describe('timelineType default', () => { + test('it should replace a query with a template value such as apache from a mock data provider', () => { + const mockDataProvider: DataProvider = mockDataProviders[0]; + mockDataProvider.queryMatch.field = 'host.name'; + mockDataProvider.id = 'Braden'; + mockDataProvider.name = 'Braden'; + mockDataProvider.queryMatch.value = 'Braden'; + const replacement = reformatDataProviderWithNewValue( + mockDataProvider, + mockEcsDataClone[0], + TimelineType.default + ); + expect(replacement).toEqual({ + id: 'apache', + name: 'apache', + enabled: true, + excluded: false, + kqlQuery: '', + queryMatch: { + field: 'host.name', + value: 'apache', + operator: ':', + displayField: undefined, + displayValue: undefined, + }, + and: [], + type: TimelineType.default, + }); }); - }); - test('it should replace a query with a template value such as apache from a mock data provider using a string in the data provider', () => { - mockEcsDataClone[0].host!.name = ('apache' as unknown) as string[]; // very unsafe cast for this test case - const mockDataProvider: DataProvider = mockDataProviders[0]; - mockDataProvider.queryMatch.field = 'host.name'; - mockDataProvider.id = 'Braden'; - mockDataProvider.name = 'Braden'; - mockDataProvider.queryMatch.value = 'Braden'; - const replacement = reformatDataProviderWithNewValue(mockDataProvider, mockEcsDataClone[0]); - expect(replacement).toEqual({ - id: 'apache', - name: 'apache', - enabled: true, - excluded: false, - kqlQuery: '', - queryMatch: { - field: 'host.name', - value: 'apache', - operator: ':', - displayField: undefined, - displayValue: undefined, - }, - and: [], + test('it should replace a query with a template value such as apache from a mock data provider using a string in the data provider', () => { + mockEcsDataClone[0].host!.name = ('apache' as unknown) as string[]; // very unsafe cast for this test case + const mockDataProvider: DataProvider = mockDataProviders[0]; + mockDataProvider.queryMatch.field = 'host.name'; + mockDataProvider.id = 'Braden'; + mockDataProvider.name = 'Braden'; + mockDataProvider.queryMatch.value = 'Braden'; + const replacement = reformatDataProviderWithNewValue( + mockDataProvider, + mockEcsDataClone[0], + TimelineType.default + ); + expect(replacement).toEqual({ + id: 'apache', + name: 'apache', + enabled: true, + excluded: false, + kqlQuery: '', + queryMatch: { + field: 'host.name', + value: 'apache', + operator: ':', + displayField: undefined, + displayValue: undefined, + }, + and: [], + type: TimelineType.default, + }); + }); + + test('it should NOT replace a query with a template value that is not part of a template such as user.id', () => { + const mockDataProvider: DataProvider = mockDataProviders[0]; + mockDataProvider.queryMatch.field = 'user.id'; + mockDataProvider.id = 'my-id'; + mockDataProvider.name = 'Rebecca'; + mockDataProvider.queryMatch.value = 'Rebecca'; + const replacement = reformatDataProviderWithNewValue( + mockDataProvider, + mockEcsDataClone[0], + TimelineType.default + ); + expect(replacement).toEqual({ + id: 'my-id', + name: 'Rebecca', + enabled: true, + excluded: false, + kqlQuery: '', + queryMatch: { + field: 'user.id', + value: 'Rebecca', + operator: ':', + displayField: undefined, + displayValue: undefined, + }, + and: [], + type: TimelineType.default, + }); }); }); - test('it should NOT replace a query with a template value that is not part of a template such as user.id', () => { - const mockDataProvider: DataProvider = mockDataProviders[0]; - mockDataProvider.queryMatch.field = 'user.id'; - mockDataProvider.id = 'my-id'; - mockDataProvider.name = 'Rebecca'; - mockDataProvider.queryMatch.value = 'Rebecca'; - const replacement = reformatDataProviderWithNewValue(mockDataProvider, mockEcsDataClone[0]); - expect(replacement).toEqual({ - id: 'my-id', - name: 'Rebecca', - enabled: true, - excluded: false, - kqlQuery: '', - queryMatch: { - field: 'user.id', - value: 'Rebecca', - operator: ':', - displayField: undefined, - displayValue: undefined, - }, - and: [], + describe('timelineType template', () => { + test('it should replace a query with a template value such as apache from a mock data provider', () => { + const mockDataProvider: DataProvider = mockDataProviders[0]; + mockDataProvider.queryMatch.field = 'host.name'; + mockDataProvider.id = 'Braden'; + mockDataProvider.name = 'Braden'; + mockDataProvider.queryMatch.value = '{host.name}'; + mockDataProvider.type = DataProviderType.template; + const replacement = reformatDataProviderWithNewValue( + mockDataProvider, + mockEcsDataClone[0], + TimelineType.template + ); + expect(replacement).toEqual({ + id: 'apache', + name: 'apache', + enabled: true, + excluded: false, + kqlQuery: '', + queryMatch: { + field: 'host.name', + value: 'apache', + operator: ':', + displayField: undefined, + displayValue: undefined, + }, + and: [], + type: DataProviderType.default, + }); + }); + + test('it should NOT replace a query for default data provider', () => { + const mockDataProvider: DataProvider = mockDataProviders[0]; + mockDataProvider.queryMatch.field = 'host.name'; + mockDataProvider.id = 'Braden'; + mockDataProvider.name = 'Braden'; + mockDataProvider.queryMatch.value = '{host.name}'; + mockDataProvider.type = DataProviderType.default; + const replacement = reformatDataProviderWithNewValue( + mockDataProvider, + mockEcsDataClone[0], + TimelineType.template + ); + expect(replacement).toEqual({ + id: 'Braden', + name: 'Braden', + enabled: true, + excluded: false, + kqlQuery: '', + queryMatch: { + field: 'host.name', + value: '{host.name}', + operator: ':', + displayField: undefined, + displayValue: undefined, + }, + and: [], + type: DataProviderType.default, + }); + }); + + test('it should replace a query with a template value such as apache from a mock data provider using a string in the data provider', () => { + mockEcsDataClone[0].host!.name = ('apache' as unknown) as string[]; // very unsafe cast for this test case + const mockDataProvider: DataProvider = mockDataProviders[0]; + mockDataProvider.queryMatch.field = 'host.name'; + mockDataProvider.id = 'Braden'; + mockDataProvider.name = 'Braden'; + mockDataProvider.queryMatch.value = '{host.name}'; + mockDataProvider.type = DataProviderType.template; + const replacement = reformatDataProviderWithNewValue( + mockDataProvider, + mockEcsDataClone[0], + TimelineType.template + ); + expect(replacement).toEqual({ + id: 'apache', + name: 'apache', + enabled: true, + excluded: false, + kqlQuery: '', + queryMatch: { + field: 'host.name', + value: 'apache', + operator: ':', + displayField: undefined, + displayValue: undefined, + }, + and: [], + type: DataProviderType.default, + }); + }); + + test('it should replace a query with a template value that is not part of a template such as user.id', () => { + const mockDataProvider: DataProvider = mockDataProviders[0]; + mockDataProvider.queryMatch.field = 'user.id'; + mockDataProvider.id = 'my-id'; + mockDataProvider.name = 'Rebecca'; + mockDataProvider.queryMatch.value = 'Rebecca'; + mockDataProvider.type = DataProviderType.default; + const replacement = reformatDataProviderWithNewValue( + mockDataProvider, + mockEcsDataClone[0], + TimelineType.template + ); + expect(replacement).toEqual({ + id: 'my-id', + name: 'Rebecca', + enabled: true, + excluded: false, + kqlQuery: '', + queryMatch: { + field: 'user.id', + value: 'Rebecca', + operator: ':', + displayField: undefined, + displayValue: undefined, + }, + and: [], + type: DataProviderType.default, + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/helpers.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/helpers.ts index 11a03b0426891..5025d782e2aa2 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/helpers.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/helpers.ts @@ -8,9 +8,10 @@ import { get, isEmpty } from 'lodash/fp'; import { Filter, esKuery, KueryNode } from '../../../../../../../src/plugins/data/public'; import { DataProvider, + DataProviderType, DataProvidersAnd, } from '../../../timelines/components/timeline/data_providers/data_provider'; -import { Ecs } from '../../../graphql/types'; +import { Ecs, TimelineType } from '../../../graphql/types'; interface FindValueToChangeInQuery { field: string; @@ -101,20 +102,28 @@ export const findValueToChangeInQuery = ( ); }; -export const replaceTemplateFieldFromQuery = (query: string, ecsData: Ecs): string => { - if (query.trim() !== '') { - const valueToChange = findValueToChangeInQuery(esKuery.fromKueryExpression(query)); - return valueToChange.reduce((newQuery, vtc) => { - const newValue = getStringArray(vtc.field, ecsData); - if (newValue.length) { - return newQuery.replace(vtc.valueToChange, newValue[0]); - } else { - return newQuery; - } - }, query); - } else { - return ''; +export const replaceTemplateFieldFromQuery = ( + query: string, + ecsData: Ecs, + timelineType: TimelineType = TimelineType.default +): string => { + if (timelineType === TimelineType.default) { + if (query.trim() !== '') { + const valueToChange = findValueToChangeInQuery(esKuery.fromKueryExpression(query)); + return valueToChange.reduce((newQuery, vtc) => { + const newValue = getStringArray(vtc.field, ecsData); + if (newValue.length) { + return newQuery.replace(vtc.valueToChange, newValue[0]); + } else { + return newQuery; + } + }, query); + } else { + return ''; + } } + + return query.trim(); }; export const replaceTemplateFieldFromMatchFilters = (filters: Filter[], ecsData: Ecs): Filter[] => @@ -135,30 +144,64 @@ export const replaceTemplateFieldFromMatchFilters = (filters: Filter[], ecsData: export const reformatDataProviderWithNewValue = ( dataProvider: T, - ecsData: Ecs + ecsData: Ecs, + timelineType: TimelineType = TimelineType.default ): T => { - if (templateFields.includes(dataProvider.queryMatch.field)) { - const newValue = getStringArray(dataProvider.queryMatch.field, ecsData); - if (newValue.length) { + // Support for legacy "template-like" timeline behavior that is using hardcoded list of templateFields + if (timelineType === TimelineType.default) { + if (templateFields.includes(dataProvider.queryMatch.field)) { + const newValue = getStringArray(dataProvider.queryMatch.field, ecsData); + if (newValue.length) { + dataProvider.id = dataProvider.id.replace(dataProvider.name, newValue[0]); + dataProvider.name = newValue[0]; + dataProvider.queryMatch.value = newValue[0]; + dataProvider.queryMatch.displayField = undefined; + dataProvider.queryMatch.displayValue = undefined; + } + } + dataProvider.type = DataProviderType.default; + return dataProvider; + } + + if (timelineType === TimelineType.template) { + if ( + dataProvider.type === DataProviderType.template && + dataProvider.queryMatch.operator === ':' + ) { + const newValue = getStringArray(dataProvider.queryMatch.field, ecsData); + + if (!newValue.length) { + dataProvider.enabled = false; + } + dataProvider.id = dataProvider.id.replace(dataProvider.name, newValue[0]); dataProvider.name = newValue[0]; dataProvider.queryMatch.value = newValue[0]; dataProvider.queryMatch.displayField = undefined; dataProvider.queryMatch.displayValue = undefined; + dataProvider.type = DataProviderType.default; + + return dataProvider; } + + dataProvider.type = dataProvider.type ?? DataProviderType.default; + + return dataProvider; } + return dataProvider; }; export const replaceTemplateFieldFromDataProviders = ( dataProviders: DataProvider[], - ecsData: Ecs + ecsData: Ecs, + timelineType: TimelineType = TimelineType.default ): DataProvider[] => dataProviders.map((dataProvider) => { - const newDataProvider = reformatDataProviderWithNewValue(dataProvider, ecsData); + const newDataProvider = reformatDataProviderWithNewValue(dataProvider, ecsData, timelineType); if (newDataProvider.and != null && !isEmpty(newDataProvider.and)) { newDataProvider.and = newDataProvider.and.map((andDataProvider) => - reformatDataProviderWithNewValue(andDataProvider, ecsData) + reformatDataProviderWithNewValue(andDataProvider, ecsData, timelineType) ); } return newDataProvider; diff --git a/x-pack/plugins/security_solution/public/graphql/introspection.json b/x-pack/plugins/security_solution/public/graphql/introspection.json index 86ee84f2e8bf4..2b8b07cb6a24b 100644 --- a/x-pack/plugins/security_solution/public/graphql/introspection.json +++ b/x-pack/plugins/security_solution/public/graphql/introspection.json @@ -10015,6 +10015,14 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "type", + "description": "", + "args": [], + "type": { "kind": "ENUM", "name": "DataProviderType", "ofType": null }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "and", "description": "", @@ -10088,6 +10096,29 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "ENUM", + "name": "DataProviderType", + "description": "", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "default", + "description": "", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "template", + "description": "", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, { "kind": "OBJECT", "name": "DateRangePickerResult", @@ -11253,6 +11284,12 @@ } }, "defaultValue": null + }, + { + "name": "type", + "description": "", + "type": { "kind": "ENUM", "name": "DataProviderType", "ofType": null }, + "defaultValue": null } ], "interfaces": null, diff --git a/x-pack/plugins/security_solution/public/graphql/types.ts b/x-pack/plugins/security_solution/public/graphql/types.ts index bf5725c2ddea5..2c8f2e63356e6 100644 --- a/x-pack/plugins/security_solution/public/graphql/types.ts +++ b/x-pack/plugins/security_solution/public/graphql/types.ts @@ -185,6 +185,8 @@ export interface DataProviderInput { queryMatch?: Maybe; and?: Maybe; + + type?: Maybe; } export interface QueryMatchInput { @@ -342,6 +344,11 @@ export enum TlsFields { _id = '_id', } +export enum DataProviderType { + default = 'default', + template = 'template', +} + export enum TimelineStatus { active = 'active', draft = 'draft', @@ -2030,6 +2037,8 @@ export interface DataProviderResult { queryMatch?: Maybe; + type?: Maybe; + and?: Maybe; } @@ -5523,6 +5532,8 @@ export namespace GetOneTimeline { kqlQuery: Maybe; + type: Maybe; + queryMatch: Maybe; and: Maybe; diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx b/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx index 8f2b3c7495f0d..4f9784b1f84bf 100644 --- a/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx @@ -45,7 +45,7 @@ const StatefulRecentTimelinesComponent = React.memo( const { formatUrl } = useFormatUrl(SecurityPageName.timelines); const { navigateToApp } = useKibana().services.application; const onOpenTimeline: OnOpenTimeline = useCallback( - ({ duplicate, timelineId }: { duplicate: boolean; timelineId: string }) => { + ({ duplicate, timelineId }) => { queryTimelineById({ apolloClient, duplicate, diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_timelines/recent_timelines.tsx b/x-pack/plugins/security_solution/public/overview/components/recent_timelines/recent_timelines.tsx index d91c2be214e8b..ddad72081645b 100644 --- a/x-pack/plugins/security_solution/public/overview/components/recent_timelines/recent_timelines.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/recent_timelines/recent_timelines.tsx @@ -20,6 +20,7 @@ import { OpenTimelineResult, } from '../../../timelines/components/open_timeline/types'; import { WithHoverActions } from '../../../common/components/with_hover_actions'; +import { TimelineType } from '../../../../common/types/timeline'; import { RecentTimelineCounts } from './counts'; import * as i18n from './translations'; @@ -58,9 +59,19 @@ export const RecentTimelines = React.memo<{ {showHoverContent && ( - + ): string[] => category.fields != null && Object.keys(category.fields).length > 0 ? Object.keys(category.fields) - : []; + : EMPTY_ARRAY_RESULT; /** Returns all field names by category, for display in an `EuiComboBox` */ export const getCategorizedFieldNames = (browserFields: BrowserFields): EuiComboBoxOptionOption[] => - Object.keys(browserFields) - .sort() - .map((categoryId) => ({ - label: categoryId, - options: getFieldNames(browserFields[categoryId]).map((fieldId) => ({ - label: fieldId, - })), - })); + !browserFields + ? EMPTY_ARRAY_RESULT + : Object.keys(browserFields) + .sort() + .map((categoryId) => ({ + label: categoryId, + options: getFieldNames(browserFields[categoryId]).map((fieldId) => ({ + label: fieldId, + })), + })); /** Returns true if the specified field name is valid */ export const selectionsAreValid = ({ @@ -61,7 +65,7 @@ export const selectionsAreValid = ({ const fieldId = selectedField.length > 0 ? selectedField[0].label : ''; const operator = selectedOperator.length > 0 ? selectedOperator[0].label : ''; - const fieldIsValid = getAllFieldsByName(browserFields)[fieldId] != null; + const fieldIsValid = browserFields && getAllFieldsByName(browserFields)[fieldId] != null; const operatorIsValid = findIndex((o) => o.label === operator, operatorLabels) !== -1; return fieldIsValid && operatorIsValid; diff --git a/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/index.test.tsx index 2160a05cb9da5..5d01995ac6380 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/index.test.tsx @@ -9,7 +9,11 @@ import React from 'react'; import { mockBrowserFields } from '../../../common/containers/source/mock'; import { TestProviders } from '../../../common/mock'; -import { IS_OPERATOR, EXISTS_OPERATOR } from '../timeline/data_providers/data_provider'; +import { + DataProviderType, + IS_OPERATOR, + EXISTS_OPERATOR, +} from '../timeline/data_providers/data_provider'; import { StatefulEditDataProvider } from '.'; @@ -266,6 +270,27 @@ describe('StatefulEditDataProvider', () => { expect(wrapper.find('[data-test-subj="value"]').exists()).toBe(false); }); + test('it does NOT render value when is template field', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="value"]').exists()).toBe(false); + }); + test('it does NOT disable the save button when field is valid', () => { const wrapper = mount( @@ -361,6 +386,7 @@ describe('StatefulEditDataProvider', () => { field: 'client.address', id: 'test', operator: ':', + type: 'default', providerId: 'hosts-table-hostName-test-host', value: 'test-host', }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/index.tsx index 95f3ec3b31649..72386a2b287f1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { noop } from 'lodash/fp'; +import { noop, startsWith, endsWith } from 'lodash/fp'; import { EuiButton, EuiComboBox, @@ -17,12 +17,12 @@ import { EuiSpacer, EuiToolTip, } from '@elastic/eui'; -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useMemo, useState, useCallback } from 'react'; import styled from 'styled-components'; import { BrowserFields } from '../../../common/containers/source'; import { OnDataProviderEdited } from '../timeline/events'; -import { QueryOperator } from '../timeline/data_providers/data_provider'; +import { DataProviderType, QueryOperator } from '../timeline/data_providers/data_provider'; import { getCategorizedFieldNames, @@ -56,6 +56,7 @@ interface Props { providerId: string; timelineId: string; value: string | number; + type?: DataProviderType; } const sanatizeValue = (value: string | number): string => @@ -83,6 +84,7 @@ export const StatefulEditDataProvider = React.memo( providerId, timelineId, value, + type = DataProviderType.default, }) => { const [updatedField, setUpdatedField] = useState([{ label: field }]); const [updatedOperator, setUpdatedOperator] = useState( @@ -105,11 +107,18 @@ export const StatefulEditDataProvider = React.memo( } }; - const onFieldSelected = useCallback((selectedField: EuiComboBoxOptionOption[]) => { - setUpdatedField(selectedField); + const onFieldSelected = useCallback( + (selectedField: EuiComboBoxOptionOption[]) => { + setUpdatedField(selectedField); - focusInput(); - }, []); + if (type === DataProviderType.template) { + setUpdatedValue(`{${selectedField[0].label}}`); + } + + focusInput(); + }, + [type] + ); const onOperatorSelected = useCallback((operatorSelected: EuiComboBoxOptionOption[]) => { setUpdatedOperator(operatorSelected); @@ -139,6 +148,36 @@ export const StatefulEditDataProvider = React.memo( window.onscroll = () => noop; }; + const handleSave = useCallback(() => { + onDataProviderEdited({ + andProviderId, + excluded: getExcludedFromSelection(updatedOperator), + field: updatedField.length > 0 ? updatedField[0].label : '', + id: timelineId, + operator: getQueryOperatorFromSelection(updatedOperator), + providerId, + value: updatedValue, + type, + }); + }, [ + onDataProviderEdited, + andProviderId, + updatedOperator, + updatedField, + timelineId, + providerId, + updatedValue, + type, + ]); + + const isValueFieldInvalid = useMemo( + () => + type !== DataProviderType.template && + (startsWith('{', sanatizeValue(updatedValue)) || + endsWith('}', sanatizeValue(updatedValue))), + [type, updatedValue] + ); + useEffect(() => { disableScrolling(); focusInput(); @@ -190,7 +229,8 @@ export const StatefulEditDataProvider = React.memo( - {updatedOperator.length > 0 && + {type !== DataProviderType.template && + updatedOperator.length > 0 && updatedOperator[0].label !== i18n.EXISTS && updatedOperator[0].label !== i18n.DOES_NOT_EXIST ? ( @@ -201,6 +241,7 @@ export const StatefulEditDataProvider = React.memo( onChange={onValueChange} placeholder={i18n.VALUE} value={sanatizeValue(updatedValue)} + isInvalid={isValueFieldInvalid} /> @@ -224,19 +265,9 @@ export const StatefulEditDataProvider = React.memo( browserFields, selectedField: updatedField, selectedOperator: updatedOperator, - }) + }) || isValueFieldInvalid } - onClick={() => { - onDataProviderEdited({ - andProviderId, - excluded: getExcludedFromSelection(updatedOperator), - field: updatedField.length > 0 ? updatedField[0].label : '', - id: timelineId, - operator: getQueryOperatorFromSelection(updatedOperator), - providerId, - value: updatedValue, - }); - }} + onClick={handleSave} size="s" > {i18n.SAVE} diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/button/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/button/index.tsx index a1392ad8b8270..5896a02b82023 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/button/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/button/index.tsx @@ -124,12 +124,13 @@ export const FlyoutButton = React.memo( diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx index 8e34e11e85729..10f20eeacbcb0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx @@ -9,10 +9,10 @@ import { connect, ConnectedProps } from 'react-redux'; import { Dispatch } from 'redux'; import { isEmpty, get } from 'lodash/fp'; +import { TimelineType } from '../../../../../common/types/timeline'; import { History } from '../../../../common/lib/history'; import { Note } from '../../../../common/lib/note'; import { appSelectors, inputsModel, inputsSelectors, State } from '../../../../common/store'; -import { defaultHeaders } from '../../timeline/body/column_headers/default_headers'; import { Properties } from '../../timeline/properties'; import { appActions } from '../../../../common/store/app'; import { inputsActions } from '../../../../common/store/inputs'; @@ -31,7 +31,6 @@ type Props = OwnProps & PropsFromRedux; const StatefulFlyoutHeader = React.memo( ({ associateNote, - createTimeline, description, graphEventId, isDataInTimeline, @@ -57,7 +56,6 @@ const StatefulFlyoutHeader = React.memo( return ( { title = '', noteIds = emptyNotesId, status, - timelineType, + timelineType = TimelineType.default, } = timeline; const history = emptyHistory; // TODO: get history from store via selector @@ -127,14 +125,6 @@ const makeMapStateToProps = () => { const mapDispatchToProps = (dispatch: Dispatch, { timelineId }: OwnProps) => ({ associateNote: (noteId: string) => dispatch(timelineActions.addNote({ id: timelineId, noteId })), - createTimeline: ({ id, show }: { id: string; show?: boolean }) => - dispatch( - timelineActions.createTimeline({ - id, - columns: defaultHeaders, - show, - }) - ), updateDescription: ({ id, description }: { id: string; description: string }) => dispatch(timelineActions.updateDescription({ id, description })), updateIsFavorite: ({ id, isFavorite }: { id: string; isFavorite: boolean }) => diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx index 15c078e175355..27fda48b69598 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx @@ -7,7 +7,7 @@ import { EuiContextMenuPanel, EuiContextMenuItem, EuiBasicTable } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; -import { TimelineStatus } from '../../../../common/types/timeline'; +import { TimelineType, TimelineStatus } from '../../../../common/types/timeline'; import * as i18n from './translations'; import { DeleteTimelines, OpenTimelineResult } from './types'; @@ -26,10 +26,12 @@ export const useEditTimelineBatchActions = ({ deleteTimelines, selectedItems, tableRef, + timelineType = TimelineType.default, }: { deleteTimelines?: DeleteTimelines; selectedItems?: OpenTimelineResult[]; tableRef: React.MutableRefObject | undefined>; + timelineType: TimelineType | null; }) => { const { enableExportTimelineDownloader, @@ -49,8 +51,7 @@ export const useEditTimelineBatchActions = ({ disableExportTimelineDownloader(); onCloseDeleteTimelineModal(); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [disableExportTimelineDownloader, onCloseDeleteTimelineModal, tableRef.current] + [disableExportTimelineDownloader, onCloseDeleteTimelineModal, tableRef] ); const selectedIds = useMemo(() => getExportedIds(selectedItems ?? []), [selectedItems]); @@ -76,7 +77,9 @@ export const useEditTimelineBatchActions = ({ onComplete={onCompleteBatchActions.bind(null, closePopover)} title={ selectedItems?.length !== 1 - ? i18n.SELECTED_TIMELINES(selectedItems?.length ?? 0) + ? timelineType === TimelineType.template + ? i18n.SELECTED_TEMPLATES(selectedItems?.length ?? 0) + : i18n.SELECTED_TIMELINES(selectedItems?.length ?? 0) : selectedItems[0]?.title ?? '' } /> @@ -106,14 +109,15 @@ export const useEditTimelineBatchActions = ({ }, // eslint-disable-next-line react-hooks/exhaustive-deps [ + selectedItems, deleteTimelines, + selectedIds, isEnableDownloader, isDeleteTimelineModalOpen, - selectedIds, - selectedItems, + onCompleteBatchActions, + timelineType, handleEnableExportTimelineDownloader, handleOnOpenDeleteTimelineModal, - onCompleteBatchActions, ] ); return { onCompleteBatchActions, getBatchItemsPopoverContent }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts index e841718c8119b..03a6d475b3426 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts @@ -4,11 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ +/* eslint-disable complexity */ + import ApolloClient from 'apollo-client'; import { getOr, set, isEmpty } from 'lodash/fp'; import { Action } from 'typescript-fsa'; import uuid from 'uuid'; import { Dispatch } from 'redux'; +import deepMerge from 'deepmerge'; import { oneTimelineQuery } from '../../containers/one/index.gql_query'; import { TimelineResult, @@ -17,9 +20,10 @@ import { FilterTimelineResult, ColumnHeaderResult, PinnedEvent, + DataProviderResult, } from '../../../graphql/types'; -import { TimelineStatus, TimelineType } from '../../../../common/types/timeline'; +import { DataProviderType, TimelineStatus, TimelineType } from '../../../../common/types/timeline'; import { addNotes as dispatchAddNotes, @@ -47,6 +51,7 @@ import { import { OpenTimelineResult, UpdateTimeline, DispatchUpdateTimeline } from './types'; import { getTimeRangeSettings } from '../../../common/utils/default_date_settings'; import { createNote } from '../notes/helpers'; +import { IS_OPERATOR } from '../timeline/data_providers/data_provider'; export const OPEN_TIMELINE_CLASS_NAME = 'open-timeline'; @@ -162,15 +167,61 @@ const setPinnedEventIds = (duplicate: boolean, pinnedEventIds: string[] | null | ? pinnedEventIds.reduce((acc, pinnedEventId) => ({ ...acc, [pinnedEventId]: true }), {}) : {}; +const getTemplateTimelineId = ( + timeline: TimelineResult, + duplicate: boolean, + targetTimelineType?: TimelineType +) => { + if (!duplicate) { + return timeline.templateTimelineId; + } + + if ( + targetTimelineType === TimelineType.default && + timeline.timelineType === TimelineType.template + ) { + return timeline.templateTimelineId; + } + + // TODO: MOVE TO BACKEND + return uuid.v4(); +}; + +const convertToDefaultField = ({ and, ...dataProvider }: DataProviderResult) => + deepMerge(dataProvider, { + type: DataProviderType.default, + queryMatch: { + value: + dataProvider.queryMatch!.operator === IS_OPERATOR ? '' : dataProvider.queryMatch!.value, + }, + }); + +const getDataProviders = ( + duplicate: boolean, + dataProviders: TimelineResult['dataProviders'], + timelineType?: TimelineType +) => { + if (duplicate && dataProviders && timelineType === TimelineType.default) { + return dataProviders.map((dataProvider) => ({ + ...convertToDefaultField(dataProvider), + and: dataProvider.and?.map(convertToDefaultField) ?? [], + })); + } + + return dataProviders; +}; + // eslint-disable-next-line complexity export const defaultTimelineToTimelineModel = ( timeline: TimelineResult, - duplicate: boolean + duplicate: boolean, + timelineType?: TimelineType ): TimelineModel => { const isTemplate = timeline.timelineType === TimelineType.template; const timelineEntries = { ...timeline, columns: timeline.columns != null ? timeline.columns.map(setTimelineColumn) : defaultHeaders, + dataProviders: getDataProviders(duplicate, timeline.dataProviders, timelineType), eventIdToNoteIds: setEventIdToNoteIds(duplicate, timeline.eventIdToNoteIds), filters: timeline.filters != null ? timeline.filters.map(setTimelineFilters) : [], isFavorite: duplicate @@ -185,8 +236,9 @@ export const defaultTimelineToTimelineModel = ( status: duplicate ? TimelineStatus.active : timeline.status, savedObjectId: duplicate ? null : timeline.savedObjectId, version: duplicate ? null : timeline.version, + timelineType: timelineType ?? timeline.timelineType, title: duplicate ? `${timeline.title} - Duplicate` : timeline.title || '', - templateTimelineId: duplicate && isTemplate ? uuid.v4() : timeline.templateTimelineId, + templateTimelineId: getTemplateTimelineId(timeline, duplicate, timelineType), templateTimelineVersion: duplicate && isTemplate ? 1 : timeline.templateTimelineVersion, }; return Object.entries(timelineEntries).reduce( @@ -200,12 +252,13 @@ export const defaultTimelineToTimelineModel = ( export const formatTimelineResultToModel = ( timelineToOpen: TimelineResult, - duplicate: boolean = false + duplicate: boolean = false, + timelineType?: TimelineType ): { notes: NoteResult[] | null | undefined; timeline: TimelineModel } => { const { notes, ...timelineModel } = timelineToOpen; return { notes, - timeline: defaultTimelineToTimelineModel(timelineModel, duplicate), + timeline: defaultTimelineToTimelineModel(timelineModel, duplicate, timelineType), }; }; @@ -214,6 +267,7 @@ export interface QueryTimelineById { duplicate?: boolean; graphEventId?: string; timelineId: string; + timelineType?: TimelineType; onOpenTimeline?: (timeline: TimelineModel) => void; openTimeline?: boolean; updateIsLoading: ({ @@ -231,6 +285,7 @@ export const queryTimelineById = ({ duplicate = false, graphEventId = '', timelineId, + timelineType, onOpenTimeline, openTimeline = true, updateIsLoading, @@ -250,7 +305,11 @@ export const queryTimelineById = ({ getOr({}, 'data.getOneTimeline', result) ); - const { timeline, notes } = formatTimelineResultToModel(timelineToOpen, duplicate); + const { timeline, notes } = formatTimelineResultToModel( + timelineToOpen, + duplicate, + timelineType + ); if (onOpenTimeline != null) { onOpenTimeline(timeline); } else if (updateTimeline) { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx index ea63f2b7b0710..6d332c79f77cd 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx @@ -7,11 +7,8 @@ import ApolloClient from 'apollo-client'; import React, { useEffect, useState, useCallback } from 'react'; import { connect, ConnectedProps } from 'react-redux'; - import { Dispatch } from 'redux'; -import { disableTemplate } from '../../../../common/constants'; - import { DeleteTimelineMutation, SortFieldTimeline, Direction } from '../../../graphql/types'; import { State } from '../../../common/store'; import { ColumnHeaderOptions, TimelineModel } from '../../../timelines/store/timeline/model'; @@ -267,7 +264,7 @@ export const StatefulOpenTimelineComponent = React.memo( }, []); const openTimeline: OnOpenTimeline = useCallback( - ({ duplicate, timelineId }: { duplicate: boolean; timelineId: string }) => { + ({ duplicate, timelineId, timelineType: timelineTypeToOpen }) => { if (isModal && closeModalTimeline != null) { closeModalTimeline(); } @@ -277,6 +274,7 @@ export const StatefulOpenTimelineComponent = React.memo( duplicate, onOpenTimeline, timelineId, + timelineType: timelineTypeToOpen, updateIsLoading, updateTimeline, }); @@ -318,9 +316,9 @@ export const StatefulOpenTimelineComponent = React.memo( selectedItems={selectedItems} sortDirection={sortDirection} sortField={sortField} - templateTimelineFilter={!disableTemplate ? templateTimelineFilter : null} + templateTimelineFilter={templateTimelineFilter} timelineType={timelineType} - timelineFilter={!disableTemplate ? timelineTabs : null} + timelineFilter={timelineTabs} title={title} totalSearchResultsCount={totalCount} /> @@ -348,9 +346,9 @@ export const StatefulOpenTimelineComponent = React.memo( selectedItems={selectedItems} sortDirection={sortDirection} sortField={sortField} - templateTimelineFilter={!disableTemplate ? templateTimelineFilter : null} + templateTimelineFilter={templateTimelineFilter} timelineType={timelineType} - timelineFilter={!disableTemplate ? timelineFilters : null} + timelineFilter={timelineFilters} title={title} totalSearchResultsCount={totalCount} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx index 849143894efe0..60b009f59c13b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx @@ -8,6 +8,7 @@ import { EuiPanel, EuiBasicTable, EuiCallOut, EuiSpacer } from '@elastic/eui'; import React, { useCallback, useMemo, useRef } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; +import { TimelineType } from '../../../../common/types/timeline'; import { ImportDataModal } from '../../../common/components/import_data_modal'; import { UtilityBarGroup, @@ -36,7 +37,6 @@ export const OpenTimeline = React.memo( isLoading, itemIdToExpandedNotesRowMap, importDataModalToggle, - onAddTimelinesToFavorites, onDeleteSelected, onlyFavorites, onOpenTimeline, @@ -54,7 +54,7 @@ export const OpenTimeline = React.memo( sortDirection, setImportDataModalToggle, sortField, - timelineType, + timelineType = TimelineType.default, timelineFilter, templateTimelineFilter, totalSearchResultsCount, @@ -73,8 +73,27 @@ export const OpenTimeline = React.memo( deleteTimelines, selectedItems, tableRef, + timelineType, }); + const nTemplates = useMemo( + () => ( + + {query.trim().length ? `${i18n.WITH} "${query.trim()}"` : ''} + + ), + }} + /> + ), + [totalSearchResultsCount, query] + ); + const nTimelines = useMemo( () => ( ( } }, [setImportDataModalToggle, refetch, searchResults, totalSearchResultsCount]); - const actionTimelineToShow = useMemo( - () => - onDeleteSelected != null && deleteTimelines != null - ? ['delete', 'duplicate', 'export', 'selectable'] - : ['duplicate', 'export', 'selectable'], - [onDeleteSelected, deleteTimelines] - ); + const actionTimelineToShow = useMemo(() => { + const timelineActions: ActionTimelineToShow[] = [ + 'createFrom', + 'duplicate', + 'export', + 'selectable', + ]; + + if (onDeleteSelected != null && deleteTimelines != null) { + timelineActions.push('delete'); + } + + return timelineActions; + }, [onDeleteSelected, deleteTimelines]); const SearchRowContent = useMemo(() => <>{templateTimelineFilter}, [templateTimelineFilter]); @@ -167,7 +193,7 @@ export const OpenTimeline = React.memo( onQueryChange={onQueryChange} onToggleOnlyFavorites={onToggleOnlyFavorites} query={query} - totalSearchResultsCount={totalSearchResultsCount} + timelineType={timelineType} > {SearchRowContent} @@ -177,13 +203,18 @@ export const OpenTimeline = React.memo( <> - {i18n.SHOWING} {nTimelines} + {i18n.SHOWING}{' '} + {timelineType === TimelineType.template ? nTemplates : nTimelines} - {i18n.SELECTED_TIMELINES(selectedItems.length)} + + {timelineType === TimelineType.template + ? i18n.SELECTED_TEMPLATES(selectedItems.length) + : i18n.SELECTED_TIMELINES(selectedItems.length)} + ( totalSearchResultsCount, }) => { const actionsToShow = useMemo(() => { - const actions: ActionTimelineToShow[] = - onDeleteSelected != null && deleteTimelines != null - ? ['delete', 'duplicate'] - : ['duplicate']; + const actions: ActionTimelineToShow[] = ['createFrom', 'duplicate']; + + if (onDeleteSelected != null && deleteTimelines != null) { + actions.push('delete'); + } + return actions.filter((action) => !hideActions.includes(action)); }, [onDeleteSelected, deleteTimelines, hideActions]); @@ -84,8 +86,8 @@ export const OpenTimelineModalBody = memo( onlyFavorites={onlyFavorites} onQueryChange={onQueryChange} onToggleOnlyFavorites={onToggleOnlyFavorites} - query={query} - totalSearchResultsCount={totalSearchResultsCount} + query="" + timelineType={timelineType} > {SearchRowContent} diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/search_row/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/search_row/index.test.tsx index 77aa306157c92..2e6dcb85ad769 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/search_row/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/search_row/index.test.tsx @@ -10,6 +10,8 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; import { ThemeProvider } from 'styled-components'; +import { TimelineType } from '../../../../../common/types/timeline'; + import { SearchRow } from '.'; import * as i18n from '../translations'; @@ -25,7 +27,7 @@ describe('SearchRow', () => { onQueryChange={jest.fn()} onToggleOnlyFavorites={jest.fn()} query="" - totalSearchResultsCount={0} + timelineType={TimelineType.default} /> ); @@ -45,7 +47,7 @@ describe('SearchRow', () => { onQueryChange={jest.fn()} onToggleOnlyFavorites={jest.fn()} query="" - totalSearchResultsCount={0} + timelineType={TimelineType.default} /> ); @@ -65,7 +67,7 @@ describe('SearchRow', () => { onQueryChange={jest.fn()} onToggleOnlyFavorites={onToggleOnlyFavorites} query="" - totalSearchResultsCount={0} + timelineType={TimelineType.default} /> ); @@ -83,7 +85,7 @@ describe('SearchRow', () => { onQueryChange={jest.fn()} onToggleOnlyFavorites={jest.fn()} query="" - totalSearchResultsCount={0} + timelineType={TimelineType.default} /> ); @@ -104,7 +106,7 @@ describe('SearchRow', () => { onQueryChange={jest.fn()} onToggleOnlyFavorites={jest.fn()} query="" - totalSearchResultsCount={0} + timelineType={TimelineType.default} /> ); @@ -129,7 +131,7 @@ describe('SearchRow', () => { onQueryChange={onQueryChange} onToggleOnlyFavorites={jest.fn()} query="" - totalSearchResultsCount={32} + timelineType={TimelineType.default} /> ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/search_row/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/search_row/index.tsx index 6f9178664ccf0..5b927db3c37a9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/search_row/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/search_row/index.tsx @@ -12,9 +12,10 @@ import { // @ts-ignore EuiSearchBar, } from '@elastic/eui'; -import React from 'react'; +import React, { useMemo } from 'react'; import styled from 'styled-components'; +import { TimelineType } from '../../../../../common/types/timeline'; import * as i18n from '../translations'; import { OpenTimelineProps } from '../types'; @@ -39,14 +40,9 @@ type Props = Pick< | 'onQueryChange' | 'onToggleOnlyFavorites' | 'query' - | 'totalSearchResultsCount' + | 'timelineType' > & { children?: JSX.Element | null }; -const searchBox = { - placeholder: i18n.SEARCH_PLACEHOLDER, - incremental: false, -}; - /** * Renders the row containing the search input and Only Favorites filter */ @@ -56,10 +52,20 @@ export const SearchRow = React.memo( onlyFavorites, onQueryChange, onToggleOnlyFavorites, - query, - totalSearchResultsCount, children, + timelineType, }) => { + const searchBox = useMemo( + () => ({ + placeholder: + timelineType === TimelineType.default + ? i18n.SEARCH_PLACEHOLDER + : i18n.SEARCH_TEMPLATE_PLACEHOLDER, + incremental: false, + }), + [timelineType] + ); + return ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.tsx index 5b8eb8fd0365c..aa4bb3f1e0467 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.tsx @@ -16,7 +16,7 @@ import { TimelineActionsOverflowColumns, } from '../types'; import * as i18n from '../translations'; -import { TimelineStatus } from '../../../../../common/types/timeline'; +import { TimelineStatus, TimelineType } from '../../../../../common/types/timeline'; /** * Returns the action columns (e.g. delete, open duplicate timeline) @@ -34,6 +34,42 @@ export const getActionsColumns = ({ onOpenDeleteTimelineModal?: OnOpenDeleteTimelineModal; onOpenTimeline: OnOpenTimeline; }): [TimelineActionsOverflowColumns] => { + const createTimelineFromTemplate = { + name: i18n.CREATE_TIMELINE_FROM_TEMPLATE, + icon: 'timeline', + onClick: ({ savedObjectId }: OpenTimelineResult) => { + onOpenTimeline({ + duplicate: true, + timelineType: TimelineType.default, + timelineId: savedObjectId!, + }); + }, + type: 'icon', + enabled: ({ savedObjectId }: OpenTimelineResult) => savedObjectId != null, + description: i18n.CREATE_TIMELINE_FROM_TEMPLATE, + 'data-test-subj': 'create-from-template', + available: (item: OpenTimelineResult) => + item.timelineType === TimelineType.template && actionTimelineToShow.includes('createFrom'), + }; + + const createTemplateFromTimeline = { + name: i18n.CREATE_TEMPLATE_FROM_TIMELINE, + icon: 'visText', + onClick: ({ savedObjectId }: OpenTimelineResult) => { + onOpenTimeline({ + duplicate: true, + timelineType: TimelineType.template, + timelineId: savedObjectId!, + }); + }, + type: 'icon', + enabled: ({ savedObjectId }: OpenTimelineResult) => savedObjectId != null, + description: i18n.CREATE_TEMPLATE_FROM_TIMELINE, + 'data-test-subj': 'create-template-from-timeline', + available: (item: OpenTimelineResult) => + item.timelineType !== TimelineType.template && actionTimelineToShow.includes('createFrom'), + }; + const openAsDuplicateColumn = { name: i18n.OPEN_AS_DUPLICATE, icon: 'copy', @@ -47,6 +83,25 @@ export const getActionsColumns = ({ enabled: ({ savedObjectId }: OpenTimelineResult) => savedObjectId != null, description: i18n.OPEN_AS_DUPLICATE, 'data-test-subj': 'open-duplicate', + available: (item: OpenTimelineResult) => + item.timelineType !== TimelineType.template && actionTimelineToShow.includes('duplicate'), + }; + + const openAsDuplicateTemplateColumn = { + name: i18n.OPEN_AS_DUPLICATE_TEMPLATE, + icon: 'copy', + onClick: ({ savedObjectId }: OpenTimelineResult) => { + onOpenTimeline({ + duplicate: true, + timelineId: savedObjectId ?? '', + }); + }, + type: 'icon', + enabled: ({ savedObjectId }: OpenTimelineResult) => savedObjectId != null, + description: i18n.OPEN_AS_DUPLICATE_TEMPLATE, + 'data-test-subj': 'open-duplicate-template', + available: (item: OpenTimelineResult) => + item.timelineType === TimelineType.template && actionTimelineToShow.includes('duplicate'), }; const exportTimelineAction = { @@ -60,6 +115,7 @@ export const getActionsColumns = ({ }, description: i18n.EXPORT_SELECTED, 'data-test-subj': 'export-timeline', + available: () => actionTimelineToShow.includes('export'), }; const deleteTimelineColumn = { @@ -72,18 +128,20 @@ export const getActionsColumns = ({ savedObjectId != null && status !== TimelineStatus.immutable, description: i18n.DELETE_SELECTED, 'data-test-subj': 'delete-timeline', + available: () => actionTimelineToShow.includes('delete') && deleteTimelines != null, }; return [ { - width: '40px', + width: '80px', actions: [ - actionTimelineToShow.includes('duplicate') ? openAsDuplicateColumn : null, - actionTimelineToShow.includes('export') ? exportTimelineAction : null, - actionTimelineToShow.includes('delete') && deleteTimelines != null - ? deleteTimelineColumn - : null, - ].filter((action) => action != null), + createTimelineFromTemplate, + createTemplateFromTimeline, + openAsDuplicateColumn, + openAsDuplicateTemplateColumn, + exportTimelineAction, + deleteTimelineColumn, + ], }, ]; }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx index e0c7ab68f6bf5..eb9ddcce112d3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx @@ -17,6 +17,7 @@ import * as i18n from '../translations'; import { OnOpenTimeline, OnToggleShowNotes, OpenTimelineResult } from '../types'; import { getEmptyTagValue } from '../../../../common/components/empty_value'; import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date'; +import { TimelineType } from '../../../../../common/types/timeline'; /** * Returns the column definitions (passed as the `columns` prop to @@ -27,10 +28,12 @@ export const getCommonColumns = ({ itemIdToExpandedNotesRowMap, onOpenTimeline, onToggleShowNotes, + timelineType, }: { onOpenTimeline: OnOpenTimeline; onToggleShowNotes: OnToggleShowNotes; itemIdToExpandedNotesRowMap: Record; + timelineType: TimelineType | null; }) => [ { isExpander: true, @@ -55,7 +58,7 @@ export const getCommonColumns = ({ { dataType: 'string', field: 'title', - name: i18n.TIMELINE_NAME, + name: timelineType === TimelineType.default ? i18n.TIMELINE_NAME : i18n.TIMELINE_TEMPLATE_NAME, render: (title: string, timelineResult: OpenTimelineResult) => timelineResult.savedObjectId != null ? ( [ - { - dataType: 'string', - field: 'updatedBy', - name: i18n.MODIFIED_BY, - render: (updatedBy: OpenTimelineResult['updatedBy']) => ( -
{defaultToEmptyTag(updatedBy)}
- ), - sortable: false, - }, -]; +export const getExtendedColumns = (showExtendedColumns: boolean) => { + if (!showExtendedColumns) return []; + + return [ + { + dataType: 'string', + field: 'updatedBy', + name: i18n.MODIFIED_BY, + render: (updatedBy: OpenTimelineResult['updatedBy']) => ( +
{defaultToEmptyTag(updatedBy)}
+ ), + sortable: false, + }, + ]; +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx index fdba3247afb38..2c55edb9034b5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx @@ -5,7 +5,7 @@ */ import { EuiBasicTable as _EuiBasicTable } from '@elastic/eui'; -import React from 'react'; +import React, { useMemo } from 'react'; import styled from 'styled-components'; import * as i18n from '../translations'; @@ -40,9 +40,6 @@ const BasicTable = styled(EuiBasicTable)` `; BasicTable.displayName = 'BasicTable'; -const getExtendedColumnsIfEnabled = (showExtendedColumns: boolean) => - showExtendedColumns ? [...getExtendedColumns()] : []; - /** * Returns the column definitions (passed as the `columns` prop to * `EuiBasicTable`) that are displayed in the compact `Open Timeline` modal @@ -77,8 +74,9 @@ export const getTimelinesTableColumns = ({ itemIdToExpandedNotesRowMap, onOpenTimeline, onToggleShowNotes, + timelineType, }), - ...getExtendedColumnsIfEnabled(showExtendedColumns), + ...getExtendedColumns(showExtendedColumns), ...getIconHeaderColumns({ timelineType }), ...getActionsColumns({ actionTimelineToShow, @@ -167,9 +165,10 @@ export const TimelinesTable = React.memo( onSelectionChange, }; const basicTableProps = tableRef != null ? { ref: tableRef } : {}; - return ( - + getTimelinesTableColumns({ actionTimelineToShow, deleteTimelines, itemIdToExpandedNotesRowMap, @@ -180,7 +179,24 @@ export const TimelinesTable = React.memo( onToggleShowNotes, showExtendedColumns, timelineType, - })} + }), + [ + actionTimelineToShow, + deleteTimelines, + itemIdToExpandedNotesRowMap, + enableExportTimelineDownloader, + onOpenDeleteTimelineModal, + onOpenTimeline, + onSelectionChange, + onToggleShowNotes, + showExtendedColumns, + timelineType, + ] + ); + + return ( + + i18n.translate('xpack.securitySolution.open.timeline.selectedTemplatesTitle', { + values: { selectedTemplates }, + defaultMessage: + 'Selected {selectedTemplates} {selectedTemplates, plural, =1 {template} other {templates}}', + }); + export const SELECTED_TIMELINES = (selectedTimelines: number) => i18n.translate('xpack.securitySolution.open.timeline.selectedTimelinesTitle', { values: { selectedTimelines }, diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts index 8811d5452e039..c21edaa916588 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts @@ -54,7 +54,7 @@ export interface OpenTimelineResult { status?: TimelineStatus | null; title?: string | null; templateTimelineId?: string | null; - type?: TimelineTypeLiteral; + timelineType?: TimelineTypeLiteral; updated?: number | null; updatedBy?: string | null; } @@ -82,9 +82,11 @@ export type OnDeleteOneTimeline = (timelineIds: string[]) => void; export type OnOpenTimeline = ({ duplicate, timelineId, + timelineType, }: { duplicate: boolean; timelineId: string; + timelineType?: TimelineTypeLiteral; }) => void; export type OnOpenDeleteTimelineModal = (selectedItem: OpenTimelineResult) => void; @@ -117,7 +119,7 @@ export interface OnTableChangeParams { /** Invoked by the EUI table implementation when the user interacts with the table */ export type OnTableChange = (tableChange: OnTableChangeParams) => void; -export type ActionTimelineToShow = 'duplicate' | 'delete' | 'export' | 'selectable'; +export type ActionTimelineToShow = 'createFrom' | 'duplicate' | 'delete' | 'export' | 'selectable'; export interface OpenTimelineProps { /** Invoked when the user clicks the delete (trash) icon on an individual timeline */ @@ -172,7 +174,7 @@ export interface OpenTimelineProps { timelineType: TimelineTypeLiteralWithNull; /** when timelineType === template, templatetimelineFilter is a JSX.Element */ templateTimelineFilter: JSX.Element[] | null; - /** timeline / template timeline */ + /** timeline / timeline template */ timelineFilter?: JSX.Element | JSX.Element[] | null; /** The title of the Open Timeline component */ title: string; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_status.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_status.tsx index f17f6aebaddf6..c321caed46f22 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_status.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_status.tsx @@ -17,7 +17,6 @@ import { import * as i18n from './translations'; import { TemplateTimelineFilter } from './types'; -import { disableTemplate } from '../../../../common/constants'; export const useTimelineStatus = ({ timelineType, @@ -33,16 +32,16 @@ export const useTimelineStatus = ({ templateTimelineFilter: JSX.Element[] | null; } => { const [selectedTab, setSelectedTab] = useState( - disableTemplate ? null : TemplateTimelineType.elastic + TemplateTimelineType.elastic ); const isTemplateFilterEnabled = useMemo(() => timelineType === TimelineType.template, [ timelineType, ]); - const templateTimelineType = useMemo( - () => (disableTemplate || !isTemplateFilterEnabled ? null : selectedTab), - [selectedTab, isTemplateFilterEnabled] - ); + const templateTimelineType = useMemo(() => (!isTemplateFilterEnabled ? null : selectedTab), [ + selectedTab, + isTemplateFilterEnabled, + ]); const timelineStatus = useMemo( () => diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap index 7baefaa6ab951..e38f6ad022d78 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap @@ -901,6 +901,7 @@ In other use cases the message field can be used to concatenate different values } indexToAdd={Array []} isLive={false} + isSaving={false} itemsPerPage={5} itemsPerPageOptions={ Array [ @@ -918,6 +919,7 @@ In other use cases the message field can be used to concatenate different values onDataProviderRemoved={[MockFunction]} onToggleDataProviderEnabled={[MockFunction]} onToggleDataProviderExcluded={[MockFunction]} + onToggleDataProviderType={[MockFunction]} show={true} showCallOutUnauthorizedMsg={false} sort={ @@ -928,6 +930,7 @@ In other use cases the message field can be used to concatenate different values } start={1521830963132} status="active" + timelineType="default" toggleColumn={[MockFunction]} usersViewing={ Array [ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/data_providers.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/data_providers.test.tsx.snap index 46a6970720def..14304b99263ac 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/data_providers.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/data_providers.test.tsx.snap @@ -144,11 +144,12 @@ exports[`DataProviders rendering renders correctly against snapshot 1`] = ` }, ] } - id="foo" onDataProviderEdited={[MockFunction]} onDataProviderRemoved={[MockFunction]} onToggleDataProviderEnabled={[MockFunction]} onToggleDataProviderExcluded={[MockFunction]} + onToggleDataProviderType={[MockFunction]} + timelineId="foo" /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/empty.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/empty.test.tsx.snap index dac95c302af27..006da47460012 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/empty.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/empty.test.tsx.snap @@ -20,8 +20,6 @@ exports[`Empty rendering renders correctly against snapshot 1`] = ` highlighted - - + `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/provider.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/provider.test.tsx.snap index 16094c585911b..d589a9aa33f06 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/provider.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/provider.test.tsx.snap @@ -11,6 +11,8 @@ exports[`Provider rendering renders correctly against snapshot 1`] = ` providerId="id-Provider 1" toggleEnabledProvider={[Function]} toggleExcludedProvider={[Function]} + toggleTypeProvider={[Function]} + type="default" val="Provider 1" /> `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/providers.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/providers.test.tsx.snap index d0d12a135e3dc..a227f39494b61 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/providers.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/providers.test.tsx.snap @@ -5,26 +5,24 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - - + - ( - + @@ -42,37 +40,37 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - ) - + + - + - ( - + @@ -90,37 +88,37 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - ) - + + - + - ( - + @@ -138,37 +136,37 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - ) - + + - + - ( - + @@ -186,37 +184,37 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - ) - + + - + - ( - + @@ -234,37 +232,37 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - ) - + + - + - ( - + @@ -282,37 +280,37 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - ) - + + - + - ( - + @@ -330,37 +328,37 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - ) - + + - + - ( - + @@ -378,37 +376,37 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - ) - + + - + - ( - + @@ -426,37 +424,37 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - ) - + + - + - ( - + @@ -474,37 +472,37 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - ) - + + - + - ( - + @@ -522,13 +520,13 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - ) - + `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx new file mode 100644 index 0000000000000..8e1c02bad50a3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx @@ -0,0 +1,198 @@ +/* + * 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 React, { useCallback, useMemo, useState } from 'react'; +import { + EuiButton, + EuiContextMenu, + EuiText, + EuiPopover, + EuiIcon, + EuiContextMenuPanelItemDescriptor, +} from '@elastic/eui'; +import uuid from 'uuid'; +import { useDispatch, useSelector } from 'react-redux'; + +import { BrowserFields } from '../../../../common/containers/source'; +import { TimelineType } from '../../../../../common/types/timeline'; +import { StatefulEditDataProvider } from '../../edit_data_provider'; +import { addContentToTimeline } from './helpers'; +import { DataProviderType } from './data_provider'; +import { timelineSelectors } from '../../../store/timeline'; +import { ADD_FIELD_LABEL, ADD_TEMPLATE_FIELD_LABEL } from './translations'; + +interface AddDataProviderPopoverProps { + browserFields: BrowserFields; + timelineId: string; +} + +const AddDataProviderPopoverComponent: React.FC = ({ + browserFields, + timelineId, +}) => { + const dispatch = useDispatch(); + const [isAddFilterPopoverOpen, setIsAddFilterPopoverOpen] = useState(false); + const timelineById = useSelector(timelineSelectors.timelineByIdSelector); + const { dataProviders, timelineType } = timelineById[timelineId] ?? {}; + + const handleOpenPopover = useCallback(() => setIsAddFilterPopoverOpen(true), [ + setIsAddFilterPopoverOpen, + ]); + + const handleClosePopover = useCallback(() => setIsAddFilterPopoverOpen(false), [ + setIsAddFilterPopoverOpen, + ]); + + const handleDataProviderEdited = useCallback( + ({ andProviderId, excluded, field, id, operator, providerId, value, type }) => { + addContentToTimeline({ + dataProviders, + destination: { + droppableId: `droppableId.timelineProviders.${timelineId}.group.${dataProviders.length}`, + index: 0, + }, + dispatch, + onAddedToTimeline: handleClosePopover, + providerToAdd: { + id: providerId, + name: value, + enabled: true, + excluded, + kqlQuery: '', + type, + queryMatch: { + displayField: undefined, + displayValue: undefined, + field, + value, + operator, + }, + and: [], + }, + timelineId, + }); + }, + [dataProviders, timelineId, dispatch, handleClosePopover] + ); + + const panels = useMemo( + () => [ + { + id: 0, + width: 400, + items: [ + { + name: ADD_FIELD_LABEL, + icon: , + panel: 1, + }, + timelineType === TimelineType.template + ? { + disabled: timelineType !== TimelineType.template, + name: ADD_TEMPLATE_FIELD_LABEL, + icon: , + panel: 2, + } + : null, + ].filter((item) => item !== null) as EuiContextMenuPanelItemDescriptor[], + }, + { + id: 1, + title: ADD_FIELD_LABEL, + width: 400, + content: ( + + ), + }, + { + id: 2, + title: ADD_TEMPLATE_FIELD_LABEL, + width: 400, + content: ( + + ), + }, + ], + [browserFields, handleDataProviderEdited, timelineId, timelineType] + ); + + const button = useMemo( + () => ( + + {ADD_FIELD_LABEL} + + ), + [handleOpenPopover] + ); + + const content = useMemo(() => { + if (timelineType === TimelineType.template) { + return ; + } + + return ( + + ); + }, [browserFields, handleDataProviderEdited, panels, timelineId, timelineType]); + + return ( + + {content} + + ); +}; + +AddDataProviderPopoverComponent.displayName = 'AddDataProviderPopoverComponent'; + +export const AddDataProviderPopover = React.memo(AddDataProviderPopoverComponent); + +AddDataProviderPopover.displayName = 'AddDataProviderPopover'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/data_provider.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/data_provider.ts index a6fd8a0ceabbe..7fe0255132bc9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/data_provider.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/data_provider.ts @@ -15,6 +15,11 @@ export const EXISTS_OPERATOR = ':*'; /** The operator applied to a field */ export type QueryOperator = ':' | ':*'; +export enum DataProviderType { + default = 'default', + template = 'template', +} + export interface QueryMatch { field: string; displayField?: string; @@ -39,7 +44,7 @@ export interface DataProvider { */ excluded: boolean; /** - * Return the KQL query who have been added by user + * Returns the KQL query who have been added by user */ kqlQuery: string; /** @@ -50,6 +55,10 @@ export interface DataProvider { * Additional query clauses that are ANDed with this query to narrow results */ and: DataProvidersAnd[]; + /** + * Returns a DataProviderType + */ + type?: DataProviderType.default | DataProviderType.template; } export type DataProvidersAnd = Pick>; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/data_providers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/data_providers.test.tsx index 3a8c0d8831217..754d7f9c47edf 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/data_providers.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/data_providers.test.tsx @@ -37,13 +37,14 @@ describe('DataProviders', () => { @@ -58,12 +59,13 @@ describe('DataProviders', () => { ); @@ -76,12 +78,13 @@ describe('DataProviders', () => { ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/empty.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/empty.test.tsx index 598d9233cb01d..e1fad47e4204e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/empty.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/empty.test.tsx @@ -13,7 +13,7 @@ import { TestProviders } from '../../../../common/mock/test_providers'; describe('Empty', () => { describe('rendering', () => { test('renders correctly against snapshot', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); @@ -22,7 +22,7 @@ describe('Empty', () => { test('it renders the expected message', () => { const wrapper = mount( - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/empty.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/empty.tsx index 691c919029261..a6e70791d1ec7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/empty.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/empty.tsx @@ -8,7 +8,9 @@ import { EuiBadge, EuiText } from '@elastic/eui'; import React from 'react'; import styled from 'styled-components'; +import { BrowserFields } from '../../../../common/containers/source'; import { AndOrBadge } from '../../../../common/components/and_or_badge'; +import { AddDataProviderPopover } from './add_data_provider_popover'; import * as i18n from './translations'; @@ -42,7 +44,7 @@ const EmptyContainer = styled.div<{ showSmallMsg: boolean }>` width: ${(props) => (props.showSmallMsg ? '60px' : 'auto')}; align-items: center; display: flex; - flex-direction: row; + flex-direction: column; flex-wrap: wrap; justify-content: center; user-select: none; @@ -72,12 +74,14 @@ const NoWrap = styled.div` NoWrap.displayName = 'NoWrap'; interface Props { + browserFields: BrowserFields; showSmallMsg?: boolean; + timelineId: string; } /** * Prompts the user to drop anything with a facet count into the data providers section. */ -export const Empty = React.memo(({ showSmallMsg = false }) => ( +export const Empty = React.memo(({ showSmallMsg = false, browserFields, timelineId }) => ( (({ showSmallMsg = false }) => ( {i18n.HIGHLIGHTED} - - - {i18n.HERE_TO_BUILD_AN} @@ -105,6 +106,8 @@ export const Empty = React.memo(({ showSmallMsg = false }) => ( {i18n.QUERY} + + )} {showSmallMsg && } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/helpers.tsx index 9dc66a930ccc0..923ef86c0bbc0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/helpers.tsx @@ -281,6 +281,7 @@ export const addProviderToGroup = ({ } const destinationGroupIndex = getGroupIndexFromDroppableId(destination.droppableId); + if ( indexIsValid({ index: destinationGroupIndex, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/index.tsx index 90411f975da0b..c9e06f89af41c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/index.tsx @@ -19,6 +19,7 @@ import { OnDataProviderRemoved, OnToggleDataProviderEnabled, OnToggleDataProviderExcluded, + OnToggleDataProviderType, } from '../events'; import { DataProvider } from './data_provider'; @@ -28,12 +29,13 @@ import { useManageTimeline } from '../../manage_timeline'; interface Props { browserFields: BrowserFields; - id: string; + timelineId: string; dataProviders: DataProvider[]; onDataProviderEdited: OnDataProviderEdited; onDataProviderRemoved: OnDataProviderRemoved; onToggleDataProviderEnabled: OnToggleDataProviderEnabled; onToggleDataProviderExcluded: OnToggleDataProviderExcluded; + onToggleDataProviderType: OnToggleDataProviderType; } const DropTargetDataProvidersContainer = styled.div` @@ -61,6 +63,7 @@ const DropTargetDataProviders = styled.div` position: relative; border: 0.2rem dashed ${(props) => props.theme.eui.euiColorMediumShade}; border-radius: 5px; + padding: 5px 0; margin: 2px 0 2px 0; min-height: 100px; overflow-y: auto; @@ -91,17 +94,18 @@ const getDroppableId = (id: string): string => `${droppableTimelineProvidersPref export const DataProviders = React.memo( ({ browserFields, - id, dataProviders, + timelineId, onDataProviderEdited, onDataProviderRemoved, onToggleDataProviderEnabled, onToggleDataProviderExcluded, + onToggleDataProviderType, }) => { const { getManageTimelineById } = useManageTimeline(); - const isLoading = useMemo(() => getManageTimelineById(id).isLoading, [ + const isLoading = useMemo(() => getManageTimelineById(timelineId).isLoading, [ getManageTimelineById, - id, + timelineId, ]); return ( @@ -112,16 +116,17 @@ export const DataProviders = React.memo( {dataProviders != null && dataProviders.length ? ( ) : ( - - + + )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider.tsx index 8fd164eb8a3e2..2b598c7cf04f0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider.tsx @@ -7,7 +7,7 @@ import { noop } from 'lodash/fp'; import React from 'react'; -import { DataProvider, IS_OPERATOR } from './data_provider'; +import { DataProvider, DataProviderType, IS_OPERATOR } from './data_provider'; import { ProviderItemBadge } from './provider_item_badge'; interface OwnProps { @@ -24,8 +24,10 @@ export const Provider = React.memo(({ dataProvider }) => ( providerId={dataProvider.id} toggleExcludedProvider={noop} toggleEnabledProvider={noop} + toggleTypeProvider={noop} val={dataProvider.queryMatch.displayValue || dataProvider.queryMatch.value} operator={dataProvider.queryMatch.operator || IS_OPERATOR} + type={dataProvider.type || DataProviderType.default} /> )); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_badge.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_badge.tsx index b3682c0d55147..af63957d35075 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_badge.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_badge.tsx @@ -10,14 +10,20 @@ import { isString } from 'lodash/fp'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; +import { TimelineType } from '../../../../../common/types/timeline'; import { getEmptyString } from '../../../../common/components/empty_value'; import { ProviderContainer } from '../../../../common/components/drag_and_drop/provider_container'; -import { EXISTS_OPERATOR, QueryOperator } from './data_provider'; +import { DataProviderType, EXISTS_OPERATOR, QueryOperator } from './data_provider'; import * as i18n from './translations'; -const ProviderBadgeStyled = (styled(EuiBadge)` +type ProviderBadgeStyledType = typeof EuiBadge & { + // https://styled-components.com/docs/api#transient-props + $timelineType: TimelineType; +}; + +const ProviderBadgeStyled = styled(EuiBadge)` .euiToolTipAnchor { &::after { font-style: normal; @@ -25,17 +31,29 @@ const ProviderBadgeStyled = (styled(EuiBadge)` padding: 0px 3px; } } + &.globalFilterItem { white-space: nowrap; + min-width: ${({ $timelineType }) => + $timelineType === TimelineType.template ? '140px' : 'none'}; + display: flex; + &.globalFilterItem-isDisabled { text-decoration: line-through; font-weight: 400; font-style: italic; } + + &.globalFilterItem-isError { + box-shadow: 0 1px 1px -1px rgba(152, 162, 179, 0.2), 0 3px 2px -2px rgba(152, 162, 179, 0.2), + inset 0 0 0 1px #bd271e; + } } + .euiBadge.euiBadge--iconLeft &.euiBadge.euiBadge--iconRight .euiBadge__content { flex-direction: row; } + .euiBadge.euiBadge--iconLeft &.euiBadge.euiBadge--iconRight .euiBadge__content @@ -43,10 +61,46 @@ const ProviderBadgeStyled = (styled(EuiBadge)` margin-right: 0; margin-left: 4px; } -` as unknown) as typeof EuiBadge; +`; ProviderBadgeStyled.displayName = 'ProviderBadgeStyled'; +const ProviderFieldBadge = styled.div` + display: block; + color: #fff; + padding: 6px 8px; + font-size: 0.6em; +`; + +const StyledTemplateFieldBadge = styled(ProviderFieldBadge)` + background: ${({ theme }) => theme.eui.euiColorVis3_behindText}; + text-transform: uppercase; +`; + +interface TemplateFieldBadgeProps { + type: DataProviderType; + toggleType: () => void; +} + +const ConvertFieldBadge = styled(ProviderFieldBadge)` + background: ${({ theme }) => theme.eui.euiColorDarkShade}; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +`; + +const TemplateFieldBadge: React.FC = ({ type, toggleType }) => { + if (type === DataProviderType.default) { + return ( + {i18n.CONVERT_TO_TEMPLATE_FIELD} + ); + } + + return {i18n.TEMPLATE_FIELD_LABEL}; +}; + interface ProviderBadgeProps { deleteProvider: () => void; field: string; @@ -55,8 +109,11 @@ interface ProviderBadgeProps { isExcluded: boolean; providerId: string; togglePopover: () => void; + toggleType: () => void; val: string | number; operator: QueryOperator; + type: DataProviderType; + timelineType: TimelineType; } const closeButtonProps = { @@ -66,7 +123,19 @@ const closeButtonProps = { }; export const ProviderBadge = React.memo( - ({ deleteProvider, field, isEnabled, isExcluded, operator, providerId, togglePopover, val }) => { + ({ + deleteProvider, + field, + isEnabled, + isExcluded, + operator, + providerId, + togglePopover, + toggleType, + val, + type, + timelineType, + }) => { const deleteFilter: React.MouseEventHandler = useCallback( (event: React.MouseEvent) => { // Make sure it doesn't also trigger the onclick for the whole badge @@ -93,34 +162,46 @@ export const ProviderBadge = React.memo( const prefix = useMemo(() => (isExcluded ? {i18n.NOT} : null), [isExcluded]); - return ( - - + const content = useMemo( + () => ( + <> {prefix} {operator !== EXISTS_OPERATOR ? ( - <> - {`${field}: `} - {`"${formattedValue}"`} - + {`${field}: "${formattedValue}"`} ) : ( {field} {i18n.EXISTS_LABEL} )} - + + ), + [field, formattedValue, operator, prefix] + ); + + return ( + + <> + + {content} + + + {timelineType === TimelineType.template && ( + + )} + ); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_actions.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_actions.tsx index 540b1b80259a0..7aa782c05c0dd 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_actions.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_actions.tsx @@ -12,9 +12,11 @@ import { import React, { FunctionComponent } from 'react'; import styled from 'styled-components'; +import { TimelineType } from '../../../../../common/types/timeline'; import { BrowserFields } from '../../../../common/containers/source'; + import { OnDataProviderEdited } from '../events'; -import { QueryOperator, EXISTS_OPERATOR } from './data_provider'; +import { DataProviderType, QueryOperator, EXISTS_OPERATOR } from './data_provider'; import { StatefulEditDataProvider } from '../../edit_data_provider'; import * as i18n from './translations'; @@ -23,6 +25,7 @@ export const EDIT_CLASS_NAME = 'edit-data-provider'; export const EXCLUDE_CLASS_NAME = 'exclude-data-provider'; export const ENABLE_CLASS_NAME = 'enable-data-provider'; export const FILTER_FOR_FIELD_PRESENT_CLASS_NAME = 'filter-for-field-present-data-provider'; +export const CONVERT_TO_FIELD_CLASS_NAME = 'convert-to-field-data-provider'; export const DELETE_CLASS_NAME = 'delete-data-provider'; interface OwnProps { @@ -41,9 +44,12 @@ interface OwnProps { operator: QueryOperator; providerId: string; timelineId?: string; + timelineType?: TimelineType; toggleEnabledProvider: () => void; toggleExcludedProvider: () => void; + toggleTypeProvider: () => void; value: string | number; + type: DataProviderType; } const MyEuiPopover = styled((EuiPopover as unknown) as FunctionComponent)< @@ -57,6 +63,27 @@ const MyEuiPopover = styled((EuiPopover as unknown) as FunctionComponent)< MyEuiPopover.displayName = 'MyEuiPopover'; +interface GetProviderActionsProps { + andProviderId?: string; + browserFields?: BrowserFields; + deleteItem: () => void; + field: string; + isEnabled: boolean; + isExcluded: boolean; + isLoading: boolean; + onDataProviderEdited?: OnDataProviderEdited; + onFilterForFieldPresent: () => void; + operator: QueryOperator; + providerId: string; + timelineId?: string; + timelineType?: TimelineType; + toggleEnabled: () => void; + toggleExcluded: () => void; + toggleType: () => void; + value: string | number; + type: DataProviderType; +} + export const getProviderActions = ({ andProviderId, browserFields, @@ -70,26 +97,13 @@ export const getProviderActions = ({ onFilterForFieldPresent, providerId, timelineId, + timelineType, toggleEnabled, toggleExcluded, + toggleType, + type, value, -}: { - andProviderId?: string; - browserFields?: BrowserFields; - deleteItem: () => void; - field: string; - isEnabled: boolean; - isExcluded: boolean; - isLoading: boolean; - onDataProviderEdited?: OnDataProviderEdited; - onFilterForFieldPresent: () => void; - operator: QueryOperator; - providerId: string; - timelineId?: string; - toggleEnabled: () => void; - toggleExcluded: () => void; - value: string | number; -}): EuiContextMenuPanelDescriptor[] => [ +}: GetProviderActionsProps): EuiContextMenuPanelDescriptor[] => [ { id: 0, items: [ @@ -121,6 +135,18 @@ export const getProviderActions = ({ name: i18n.FILTER_FOR_FIELD_PRESENT, onClick: onFilterForFieldPresent, }, + timelineType === TimelineType.template + ? { + className: CONVERT_TO_FIELD_CLASS_NAME, + disabled: isLoading, + icon: 'visText', + name: + type === DataProviderType.template + ? i18n.CONVERT_TO_FIELD + : i18n.CONVERT_TO_TEMPLATE_FIELD, + onClick: toggleType, + } + : { name: null }, { className: DELETE_CLASS_NAME, disabled: isLoading, @@ -128,7 +154,7 @@ export const getProviderActions = ({ name: i18n.DELETE_DATA_PROVIDER, onClick: deleteItem, }, - ], + ].filter((item) => item.name != null), }, { content: @@ -143,6 +169,7 @@ export const getProviderActions = ({ providerId={providerId} timelineId={timelineId} value={value} + type={type} /> ) : null, id: 1, @@ -167,9 +194,12 @@ export class ProviderItemActions extends React.PureComponent { operator, providerId, timelineId, + timelineType, toggleEnabledProvider, toggleExcludedProvider, + toggleTypeProvider, value, + type, } = this.props; const panelTree = getProviderActions({ @@ -185,9 +215,12 @@ export class ProviderItemActions extends React.PureComponent { operator, providerId, timelineId, + timelineType, toggleEnabled: toggleEnabledProvider, toggleExcluded: toggleExcludedProvider, + toggleType: toggleTypeProvider, value, + type, }); return ( @@ -214,6 +247,7 @@ export class ProviderItemActions extends React.PureComponent { operator, providerId, value, + type, }) => { if (this.props.onDataProviderEdited != null) { this.props.onDataProviderEdited({ @@ -224,6 +258,7 @@ export class ProviderItemActions extends React.PureComponent { operator, providerId, value, + type, }); } @@ -231,7 +266,7 @@ export class ProviderItemActions extends React.PureComponent { }; private onFilterForFieldPresent = () => { - const { andProviderId, field, timelineId, providerId, value } = this.props; + const { andProviderId, field, timelineId, providerId, value, type } = this.props; if (this.props.onDataProviderEdited != null) { this.props.onDataProviderEdited({ @@ -242,6 +277,7 @@ export class ProviderItemActions extends React.PureComponent { operator: EXISTS_OPERATOR, providerId, value, + type, }); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_badge.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_badge.tsx index 1f6fe998a44e9..bc7c313553f1e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_badge.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_badge.tsx @@ -6,14 +6,16 @@ import { noop } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; +import { TimelineType } from '../../../../../common/types/timeline'; import { BrowserFields } from '../../../../common/containers/source'; +import { timelineSelectors } from '../../../store/timeline'; import { OnDataProviderEdited } from '../events'; import { ProviderBadge } from './provider_badge'; import { ProviderItemActions } from './provider_item_actions'; -import { DataProvidersAnd, QueryOperator } from './data_provider'; +import { DataProvidersAnd, DataProviderType, QueryOperator } from './data_provider'; import { dragAndDropActions } from '../../../../common/store/drag_and_drop'; import { useManageTimeline } from '../../manage_timeline'; @@ -32,7 +34,9 @@ interface ProviderItemBadgeProps { timelineId?: string; toggleEnabledProvider: () => void; toggleExcludedProvider: () => void; + toggleTypeProvider: () => void; val: string | number; + type?: DataProviderType; } export const ProviderItemBadge = React.memo( @@ -51,8 +55,12 @@ export const ProviderItemBadge = React.memo( timelineId, toggleEnabledProvider, toggleExcludedProvider, + toggleTypeProvider, val, + type = DataProviderType.default, }) => { + const timelineById = useSelector(timelineSelectors.timelineByIdSelector); + const timelineType = timelineId ? timelineById[timelineId]?.timelineType : TimelineType.default; const { getManageTimelineById } = useManageTimeline(); const isLoading = useMemo(() => getManageTimelineById(timelineId ?? '').isLoading, [ getManageTimelineById, @@ -71,14 +79,17 @@ export const ProviderItemBadge = React.memo( const onToggleEnabledProvider = useCallback(() => { toggleEnabledProvider(); closePopover(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [toggleEnabledProvider]); + }, [closePopover, toggleEnabledProvider]); const onToggleExcludedProvider = useCallback(() => { toggleExcludedProvider(); closePopover(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [toggleExcludedProvider]); + }, [toggleExcludedProvider, closePopover]); + + const onToggleTypeProvider = useCallback(() => { + toggleTypeProvider(); + closePopover(); + }, [toggleTypeProvider, closePopover]); const [providerRegistered, setProviderRegistered] = useState(false); @@ -102,27 +113,31 @@ export const ProviderItemBadge = React.memo( () => () => { unRegisterProvider(); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [] + [unRegisterProvider] + ); + + const button = ( + ); return ( - } + button={button} closePopover={closePopover} deleteProvider={deleteProvider} field={field} @@ -135,9 +150,12 @@ export const ProviderItemBadge = React.memo( operator={operator} providerId={providerId} timelineId={timelineId} + timelineType={timelineType} toggleEnabledProvider={onToggleEnabledProvider} toggleExcludedProvider={onToggleExcludedProvider} + toggleTypeProvider={onToggleTypeProvider} value={val} + type={type} /> ); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.test.tsx index 9dc0b76224458..b788f70cb2e4a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.test.tsx @@ -38,11 +38,12 @@ describe('Providers', () => { ); expect(wrapper).toMatchSnapshot(); @@ -55,11 +56,12 @@ describe('Providers', () => { @@ -82,11 +84,12 @@ describe('Providers', () => { @@ -107,11 +110,12 @@ describe('Providers', () => { @@ -134,11 +138,12 @@ describe('Providers', () => { @@ -163,11 +168,12 @@ describe('Providers', () => { @@ -195,11 +201,12 @@ describe('Providers', () => { @@ -227,11 +234,12 @@ describe('Providers', () => { @@ -260,11 +268,12 @@ describe('Providers', () => { @@ -295,11 +304,12 @@ describe('Providers', () => { @@ -330,11 +340,12 @@ describe('Providers', () => { @@ -344,9 +355,9 @@ describe('Providers', () => { '[data-test-subj="providerBadge"] .euiBadge__content span.field-value' ); const andProviderBadgesText = andProviderBadges.map((node) => node.text()).join(' '); - expect(andProviderBadges.length).toEqual(6); + expect(andProviderBadges.length).toEqual(3); expect(andProviderBadgesText).toEqual( - 'name: "Provider 1" name: "Provider 2" name: "Provider 3"' + 'name: "Provider 1" name: "Provider 2" name: "Provider 3"' ); }); @@ -361,11 +372,12 @@ describe('Providers', () => { @@ -395,11 +407,12 @@ describe('Providers', () => { @@ -429,11 +442,12 @@ describe('Providers', () => { @@ -472,11 +486,12 @@ describe('Providers', () => { @@ -511,11 +526,12 @@ describe('Providers', () => { @@ -554,11 +570,12 @@ describe('Providers', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx index b5d44cf854458..c9dd906cee59b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx @@ -4,13 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiFormHelpText } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiFormHelpText, EuiSpacer } from '@elastic/eui'; import { rgba } from 'polished'; -import React, { useMemo } from 'react'; +import React, { Fragment, useMemo } from 'react'; import { Draggable, DraggingStyle, Droppable, NotDraggingStyle } from 'react-beautiful-dnd'; import styled, { css } from 'styled-components'; import { AndOrBadge } from '../../../../common/components/and_or_badge'; +import { AddDataProviderPopover } from './add_data_provider_popover'; import { BrowserFields } from '../../../../common/containers/source'; import { getTimelineProviderDroppableId, @@ -22,9 +23,10 @@ import { OnDataProviderRemoved, OnToggleDataProviderEnabled, OnToggleDataProviderExcluded, + OnToggleDataProviderType, } from '../events'; -import { DataProvider, DataProvidersAnd, IS_OPERATOR } from './data_provider'; +import { DataProvider, DataProviderType, DataProvidersAnd, IS_OPERATOR } from './data_provider'; import { EMPTY_GROUP, flattenIntoAndGroups } from './helpers'; import { ProviderItemBadge } from './provider_item_badge'; @@ -32,12 +34,13 @@ export const EMPTY_PROVIDERS_GROUP_CLASS_NAME = 'empty-providers-group'; interface Props { browserFields: BrowserFields; - id: string; + timelineId: string; dataProviders: DataProvider[]; onDataProviderEdited: OnDataProviderEdited; onDataProviderRemoved: OnDataProviderRemoved; onToggleDataProviderEnabled: OnToggleDataProviderEnabled; onToggleDataProviderExcluded: OnToggleDataProviderExcluded; + onToggleDataProviderType: OnToggleDataProviderType; } /** @@ -62,7 +65,8 @@ const getItemStyle = ( }); const DroppableContainer = styled.div` - height: ${ROW_OF_DATA_PROVIDERS_HEIGHT}px; + min-height: ${ROW_OF_DATA_PROVIDERS_HEIGHT}px; + height: auto !important; .${IS_DRAGGING_CLASS_NAME} &:hover { background-color: ${({ theme }) => rgba(theme.eui.euiColorSuccess, 0.2)} !important; @@ -78,10 +82,10 @@ const Parens = styled.span` `} `; -const AndOrBadgeContainer = styled.div<{ hideBadge: boolean }>` - span { - visibility: ${({ hideBadge }) => (hideBadge ? 'hidden' : 'inherit')}; - } +const AndOrBadgeContainer = styled.div` + width: 121px; + display: flex; + justify-content: flex-end; `; const LastAndOrBadgeInGroup = styled.div` @@ -105,6 +109,17 @@ const TimelineEuiFormHelpText = styled(EuiFormHelpText)` TimelineEuiFormHelpText.displayName = 'TimelineEuiFormHelpText'; +const ParensContainer = styled(EuiFlexItem)` + align-self: center; +`; + +const AddDataProviderContainer = styled.div` + padding-right: 9px; +`; + +const getDataProviderValue = (dataProvider: DataProvidersAnd) => + dataProvider.queryMatch.displayValue ?? dataProvider.queryMatch.value; + /** * Renders an interactive card representation of the data providers. It also * affords uniform UI controls for the following actions: @@ -115,148 +130,178 @@ TimelineEuiFormHelpText.displayName = 'TimelineEuiFormHelpText'; export const Providers = React.memo( ({ browserFields, - id, + timelineId, dataProviders, onDataProviderEdited, onDataProviderRemoved, onToggleDataProviderEnabled, onToggleDataProviderExcluded, + onToggleDataProviderType, }) => { // Transform the dataProviders into flattened groups, and append an empty group const dataProviderGroups: DataProvidersAnd[][] = useMemo( () => [...flattenIntoAndGroups(dataProviders), ...EMPTY_GROUP], [dataProviders] ); + return (
{dataProviderGroups.map((group, groupIndex) => ( - - - - - - - - {'('} - - - - {(droppableProvided) => ( - - {group.map((dataProvider, index) => ( - - {(provided, snapshot) => ( -
- - - 0 ? dataProvider.id : undefined} - browserFields={browserFields} - deleteProvider={() => - index > 0 - ? onDataProviderRemoved(group[0].id, dataProvider.id) - : onDataProviderRemoved(dataProvider.id) - } - field={ - index > 0 - ? dataProvider.queryMatch.displayField ?? - dataProvider.queryMatch.field - : group[0].queryMatch.displayField ?? - group[0].queryMatch.field - } - kqlQuery={index > 0 ? dataProvider.kqlQuery : group[0].kqlQuery} - isEnabled={index > 0 ? dataProvider.enabled : group[0].enabled} - isExcluded={index > 0 ? dataProvider.excluded : group[0].excluded} - onDataProviderEdited={onDataProviderEdited} - operator={ - index > 0 - ? dataProvider.queryMatch.operator ?? IS_OPERATOR - : group[0].queryMatch.operator ?? IS_OPERATOR - } - register={dataProvider} - providerId={index > 0 ? group[0].id : dataProvider.id} - timelineId={id} - toggleEnabledProvider={() => - index > 0 - ? onToggleDataProviderEnabled({ - providerId: group[0].id, - enabled: !dataProvider.enabled, - andProviderId: dataProvider.id, - }) - : onToggleDataProviderEnabled({ - providerId: dataProvider.id, - enabled: !dataProvider.enabled, - }) - } - toggleExcludedProvider={() => - index > 0 - ? onToggleDataProviderExcluded({ - providerId: group[0].id, - excluded: !dataProvider.excluded, - andProviderId: dataProvider.id, - }) - : onToggleDataProviderExcluded({ - providerId: dataProvider.id, - excluded: !dataProvider.excluded, - }) - } - val={ - dataProvider.queryMatch.displayValue ?? - dataProvider.queryMatch.value - } - /> - - - {!snapshot.isDragging && - (index < group.length - 1 ? ( - - ) : ( - - - - ))} - - -
- )} -
- ))} - {droppableProvided.placeholder} -
+ + {groupIndex !== 0 && } + + + + {groupIndex === 0 ? ( + + + + ) : ( + + + )} -
-
- - {')'} - -
+ + + {'('} + + + + {(droppableProvided) => ( + + {group.map((dataProvider, index) => ( + + {(provided, snapshot) => ( +
+ + + 0 ? dataProvider.id : undefined} + browserFields={browserFields} + deleteProvider={() => + index > 0 + ? onDataProviderRemoved(group[0].id, dataProvider.id) + : onDataProviderRemoved(dataProvider.id) + } + field={ + index > 0 + ? dataProvider.queryMatch.displayField ?? + dataProvider.queryMatch.field + : group[0].queryMatch.displayField ?? + group[0].queryMatch.field + } + kqlQuery={index > 0 ? dataProvider.kqlQuery : group[0].kqlQuery} + isEnabled={index > 0 ? dataProvider.enabled : group[0].enabled} + isExcluded={ + index > 0 ? dataProvider.excluded : group[0].excluded + } + onDataProviderEdited={onDataProviderEdited} + operator={ + index > 0 + ? dataProvider.queryMatch.operator ?? IS_OPERATOR + : group[0].queryMatch.operator ?? IS_OPERATOR + } + register={dataProvider} + providerId={index > 0 ? group[0].id : dataProvider.id} + timelineId={timelineId} + toggleEnabledProvider={() => + index > 0 + ? onToggleDataProviderEnabled({ + providerId: group[0].id, + enabled: !dataProvider.enabled, + andProviderId: dataProvider.id, + }) + : onToggleDataProviderEnabled({ + providerId: dataProvider.id, + enabled: !dataProvider.enabled, + }) + } + toggleExcludedProvider={() => + index > 0 + ? onToggleDataProviderExcluded({ + providerId: group[0].id, + excluded: !dataProvider.excluded, + andProviderId: dataProvider.id, + }) + : onToggleDataProviderExcluded({ + providerId: dataProvider.id, + excluded: !dataProvider.excluded, + }) + } + toggleTypeProvider={() => + index > 0 + ? onToggleDataProviderType({ + providerId: group[0].id, + type: + dataProvider.type === DataProviderType.template + ? DataProviderType.default + : DataProviderType.template, + andProviderId: dataProvider.id, + }) + : onToggleDataProviderType({ + providerId: dataProvider.id, + type: + dataProvider.type === DataProviderType.template + ? DataProviderType.default + : DataProviderType.template, + }) + } + val={getDataProviderValue(dataProvider)} + type={dataProvider.type} + /> + + + {!snapshot.isDragging && + (index < group.length - 1 ? ( + + ) : ( + + + + ))} + + +
+ )} +
+ ))} + {droppableProvided.placeholder} +
+ )} +
+
+ + {')'} + + + ))}
); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/translations.ts index 104ff44cb9b7c..48f1f4e2218d2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/translations.ts @@ -72,6 +72,20 @@ export const FILTER_FOR_FIELD_PRESENT = i18n.translate( } ); +export const CONVERT_TO_FIELD = i18n.translate( + 'xpack.securitySolution.dataProviders.convertToFieldLabel', + { + defaultMessage: 'Convert to field', + } +); + +export const CONVERT_TO_TEMPLATE_FIELD = i18n.translate( + 'xpack.securitySolution.dataProviders.convertToTemplateFieldLabel', + { + defaultMessage: 'Convert to template field', + } +); + export const HIGHLIGHTED = i18n.translate('xpack.securitySolution.dataProviders.highlighted', { defaultMessage: 'highlighted', }); @@ -148,3 +162,24 @@ export const VALUE_ARIA_LABEL = i18n.translate( defaultMessage: 'value', } ); + +export const ADD_FIELD_LABEL = i18n.translate( + 'xpack.securitySolution.dataProviders.addFieldPopoverButtonLabel', + { + defaultMessage: 'Add field', + } +); + +export const ADD_TEMPLATE_FIELD_LABEL = i18n.translate( + 'xpack.securitySolution.dataProviders.addTemplateFieldPopoverButtonLabel', + { + defaultMessage: 'Add template field', + } +); + +export const TEMPLATE_FIELD_LABEL = i18n.translate( + 'xpack.securitySolution.dataProviders.templateFieldLabel', + { + defaultMessage: 'Template field', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/events.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/events.ts index 6c9a9b8b89679..4653880739c6d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/events.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/events.ts @@ -7,7 +7,7 @@ import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { ColumnId } from './body/column_id'; import { SortDirection } from './body/sort'; -import { QueryOperator } from './data_providers/data_provider'; +import { DataProvider, DataProviderType, QueryOperator } from './data_providers/data_provider'; /** Invoked when a user clicks the close button to remove a data provider */ export type OnDataProviderRemoved = (providerId: string, andProviderId?: string) => void; @@ -26,6 +26,13 @@ export type OnToggleDataProviderExcluded = (excluded: { andProviderId?: string; }) => void; +/** Invoked when a user toggles type (can "default" or "template") of a data provider */ +export type OnToggleDataProviderType = (type: { + providerId: string; + type: DataProviderType; + andProviderId?: string; +}) => void; + /** Invoked when a user edits the properties of a data provider */ export type OnDataProviderEdited = ({ andProviderId, @@ -35,6 +42,7 @@ export type OnDataProviderEdited = ({ operator, providerId, value, + type, }: { andProviderId?: string; excluded: boolean; @@ -43,6 +51,7 @@ export type OnDataProviderEdited = ({ operator: QueryOperator; providerId: string; value: string | number; + type: DataProvider['type']; }) => void; /** Invoked when a user change the kql query of our data provider */ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/__snapshots__/index.test.tsx.snap index b3b39236150ec..f94c30c5a102d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/__snapshots__/index.test.tsx.snap @@ -138,11 +138,12 @@ exports[`Header rendering renders correctly against snapshot 1`] = ` }, ] } - id="foo" onDataProviderEdited={[MockFunction]} onDataProviderRemoved={[MockFunction]} onToggleDataProviderEnabled={[MockFunction]} onToggleDataProviderExcluded={[MockFunction]} + onToggleDataProviderType={[MockFunction]} + timelineId="foo" /> { browserFields: {}, dataProviders: mockDataProviders, filterManager: new FilterManager(mockUiSettingsForFilterManager), - id: 'foo', indexPattern, onDataProviderEdited: jest.fn(), onDataProviderRemoved: jest.fn(), onToggleDataProviderEnabled: jest.fn(), onToggleDataProviderExcluded: jest.fn(), + onToggleDataProviderType: jest.fn(), show: true, showCallOutUnauthorizedMsg: false, status: TimelineStatus.active, + timelineId: 'foo', + timelineType: TimelineType.default, }; describe('rendering', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx index 0541dee4b1e52..93af374b15b56 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx @@ -17,6 +17,7 @@ import { OnDataProviderRemoved, OnToggleDataProviderEnabled, OnToggleDataProviderExcluded, + OnToggleDataProviderType, } from '../events'; import { StatefulSearchOrFilter } from '../search_or_filter'; import { BrowserFields } from '../../../../common/containers/source'; @@ -32,20 +33,20 @@ interface Props { dataProviders: DataProvider[]; filterManager: FilterManager; graphEventId?: string; - id: string; indexPattern: IIndexPattern; onDataProviderEdited: OnDataProviderEdited; onDataProviderRemoved: OnDataProviderRemoved; onToggleDataProviderEnabled: OnToggleDataProviderEnabled; onToggleDataProviderExcluded: OnToggleDataProviderExcluded; + onToggleDataProviderType: OnToggleDataProviderType; show: boolean; showCallOutUnauthorizedMsg: boolean; status: TimelineStatusLiteralWithNull; + timelineId: string; } const TimelineHeaderComponent: React.FC = ({ browserFields, - id, indexPattern, dataProviders, filterManager, @@ -54,9 +55,11 @@ const TimelineHeaderComponent: React.FC = ({ onDataProviderRemoved, onToggleDataProviderEnabled, onToggleDataProviderExcluded, + onToggleDataProviderType, show, showCallOutUnauthorizedMsg, status, + timelineId, }) => ( <> {showCallOutUnauthorizedMsg && ( @@ -81,19 +84,20 @@ const TimelineHeaderComponent: React.FC = ({ <> )} @@ -104,7 +108,6 @@ export const TimelineHeader = React.memo( TimelineHeaderComponent, (prevProps, nextProps) => deepEqual(prevProps.browserFields, nextProps.browserFields) && - prevProps.id === nextProps.id && deepEqual(prevProps.indexPattern, nextProps.indexPattern) && deepEqual(prevProps.dataProviders, nextProps.dataProviders) && prevProps.filterManager === nextProps.filterManager && @@ -113,7 +116,9 @@ export const TimelineHeader = React.memo( prevProps.onDataProviderRemoved === nextProps.onDataProviderRemoved && prevProps.onToggleDataProviderEnabled === nextProps.onToggleDataProviderEnabled && prevProps.onToggleDataProviderExcluded === nextProps.onToggleDataProviderExcluded && + prevProps.onToggleDataProviderType === nextProps.onToggleDataProviderType && prevProps.show === nextProps.show && prevProps.showCallOutUnauthorizedMsg === nextProps.showCallOutUnauthorizedMsg && - prevProps.status === nextProps.status + prevProps.status === nextProps.status && + prevProps.timelineId === nextProps.timelineId ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.test.tsx index 1038ac4b69587..391d367ad3dc3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.test.tsx @@ -7,6 +7,7 @@ import { cloneDeep } from 'lodash/fp'; import { mockIndexPattern } from '../../../common/mock'; +import { DataProviderType } from './data_providers/data_provider'; import { mockDataProviders } from './data_providers/mock/mock_data_providers'; import { buildGlobalQuery, combineQueries } from './helpers'; import { mockBrowserFields } from '../../../common/containers/source/mock'; @@ -23,6 +24,20 @@ describe('Build KQL Query', () => { expect(cleanUpKqlQuery(kqlQuery)).toEqual('name : "Provider 1"'); }); + test('Build KQL query with one template data provider', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].type = DataProviderType.template; + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual('name :*'); + }); + + test('Build KQL query with one disabled data provider', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); + dataProviders[0].enabled = false; + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual(''); + }); + test('Build KQL query with one data provider as timestamp (string input)', () => { const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); dataProviders[0].queryMatch.field = '@timestamp'; @@ -75,6 +90,20 @@ describe('Build KQL Query', () => { expect(cleanUpKqlQuery(kqlQuery)).toEqual('name : "Provider 1"'); }); + test('Build KQL query with two data provider (first is template)', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 2)); + dataProviders[0].type = DataProviderType.template; + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual('(name :*) or (name : "Provider 2")'); + }); + + test('Build KQL query with two data provider (second is template)', () => { + const dataProviders = cloneDeep(mockDataProviders.slice(0, 2)); + dataProviders[1].type = DataProviderType.template; + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual('(name : "Provider 1") or (name :*)'); + }); + test('Build KQL query with one data provider and one and', () => { const dataProviders = cloneDeep(mockDataProviders.slice(0, 1)); dataProviders[0].and = cloneDeep(mockDataProviders.slice(1, 2)); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx index a3fc692c3a8a8..a0087ab638dbf 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx @@ -9,7 +9,12 @@ import memoizeOne from 'memoize-one'; import { escapeQueryValue, convertToBuildEsQuery } from '../../../common/lib/keury'; -import { DataProvider, DataProvidersAnd, EXISTS_OPERATOR } from './data_providers/data_provider'; +import { + DataProvider, + DataProviderType, + DataProvidersAnd, + EXISTS_OPERATOR, +} from './data_providers/data_provider'; import { BrowserFields } from '../../../common/containers/source'; import { IIndexPattern, @@ -52,7 +57,8 @@ const buildQueryMatch = ( browserFields: BrowserFields ) => `${dataProvider.excluded ? 'NOT ' : ''}${ - dataProvider.queryMatch.operator !== EXISTS_OPERATOR + dataProvider.queryMatch.operator !== EXISTS_OPERATOR && + dataProvider.type !== DataProviderType.template ? checkIfFieldTypeIsDate(dataProvider.queryMatch.field, browserFields) ? convertDateFieldToQuery(dataProvider.queryMatch.field, dataProvider.queryMatch.value) : `${dataProvider.queryMatch.field} : ${ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx index c88f36a2fb16b..50a7782012b76 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx @@ -76,6 +76,7 @@ describe('StatefulTimeline', () => { graphEventId: undefined, id: 'foo', isLive: false, + isSaving: false, isTimelineExists: false, itemsPerPage: 5, itemsPerPageOptions: [5, 10, 20], @@ -95,6 +96,7 @@ describe('StatefulTimeline', () => { updateDataProviderEnabled: timelineActions.updateDataProviderEnabled, updateDataProviderExcluded: timelineActions.updateDataProviderExcluded, updateDataProviderKqlQuery: timelineActions.updateDataProviderKqlQuery, + updateDataProviderType: timelineActions.updateDataProviderType, updateHighlightedDropAndProviderId: timelineActions.updateHighlightedDropAndProviderId, updateItemsPerPage: timelineActions.updateItemsPerPage, updateItemsPerPageOptions: timelineActions.updateItemsPerPageOptions, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index 93ccf6992d1f5..5265efc8109a4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { isEmpty } from 'lodash/fp'; import React, { useEffect, useCallback, useMemo } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import deepEqual from 'fast-deep-equal'; @@ -22,6 +23,7 @@ import { OnDataProviderEdited, OnToggleDataProviderEnabled, OnToggleDataProviderExcluded, + OnToggleDataProviderType, } from './events'; import { Timeline } from './timeline'; @@ -44,6 +46,7 @@ const StatefulTimelineComponent = React.memo( graphEventId, id, isLive, + isSaving, isTimelineExists, itemsPerPage, itemsPerPageOptions, @@ -61,6 +64,7 @@ const StatefulTimelineComponent = React.memo( timelineType, updateDataProviderEnabled, updateDataProviderExcluded, + updateDataProviderType, updateItemsPerPage, upsertColumn, usersViewing, @@ -82,8 +86,7 @@ const StatefulTimelineComponent = React.memo( const onDataProviderRemoved: OnDataProviderRemoved = useCallback( (providerId: string, andProviderId?: string) => removeProvider!({ id, providerId, andProviderId }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [id] + [id, removeProvider] ); const onToggleDataProviderEnabled: OnToggleDataProviderEnabled = useCallback( @@ -94,8 +97,7 @@ const StatefulTimelineComponent = React.memo( providerId, andProviderId, }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [id] + [id, updateDataProviderEnabled] ); const onToggleDataProviderExcluded: OnToggleDataProviderExcluded = useCallback( @@ -106,8 +108,18 @@ const StatefulTimelineComponent = React.memo( providerId, andProviderId, }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [id] + [id, updateDataProviderExcluded] + ); + + const onToggleDataProviderType: OnToggleDataProviderType = useCallback( + ({ providerId, type, andProviderId }) => + updateDataProviderType!({ + id, + type, + providerId, + andProviderId, + }), + [id, updateDataProviderType] ); const onDataProviderEditedLocal: OnDataProviderEdited = useCallback( @@ -121,14 +133,12 @@ const StatefulTimelineComponent = React.memo( providerId, value, }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [id] + [id, onDataProviderEdited] ); const onChangeItemsPerPage: OnChangeItemsPerPage = useCallback( (itemsChangedPerPage) => updateItemsPerPage!({ id, itemsPerPage: itemsChangedPerPage }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [id] + [id, updateItemsPerPage] ); const toggleColumn = useCallback( @@ -176,6 +186,7 @@ const StatefulTimelineComponent = React.memo( indexPattern={indexPattern} indexToAdd={indexToAdd} isLive={isLive} + isSaving={isSaving} itemsPerPage={itemsPerPage!} itemsPerPageOptions={itemsPerPageOptions!} kqlMode={kqlMode} @@ -187,12 +198,14 @@ const StatefulTimelineComponent = React.memo( onDataProviderRemoved={onDataProviderRemoved} onToggleDataProviderEnabled={onToggleDataProviderEnabled} onToggleDataProviderExcluded={onToggleDataProviderExcluded} + onToggleDataProviderType={onToggleDataProviderType} show={show!} showCallOutUnauthorizedMsg={showCallOutUnauthorizedMsg} sort={sort!} start={start} status={status} toggleColumn={toggleColumn} + timelineType={timelineType} usersViewing={usersViewing} /> ); @@ -204,6 +217,7 @@ const StatefulTimelineComponent = React.memo( prevProps.graphEventId === nextProps.graphEventId && prevProps.id === nextProps.id && prevProps.isLive === nextProps.isLive && + prevProps.isSaving === nextProps.isSaving && prevProps.itemsPerPage === nextProps.itemsPerPage && prevProps.kqlMode === nextProps.kqlMode && prevProps.kqlQueryExpression === nextProps.kqlQueryExpression && @@ -240,15 +254,19 @@ const makeMapStateToProps = () => { graphEventId, itemsPerPage, itemsPerPageOptions, + isSaving, kqlMode, show, sort, status, timelineType, } = timeline; - const kqlQueryExpression = getKqlQueryTimeline(state, id)!; - + const kqlQueryTimeline = getKqlQueryTimeline(state, id)!; const timelineFilter = kqlMode === 'filter' ? filters || [] : []; + + // return events on empty search + const kqlQueryExpression = + isEmpty(dataProviders) && isEmpty(kqlQueryTimeline) ? ' ' : kqlQueryTimeline; return { columns, dataProviders, @@ -258,6 +276,7 @@ const makeMapStateToProps = () => { graphEventId, id, isLive: input.policy.kind === 'interval', + isSaving, isTimelineExists: getTimeline(state, id) != null, itemsPerPage, itemsPerPageOptions, @@ -284,6 +303,7 @@ const mapDispatchToProps = { updateDataProviderEnabled: timelineActions.updateDataProviderEnabled, updateDataProviderExcluded: timelineActions.updateDataProviderExcluded, updateDataProviderKqlQuery: timelineActions.updateDataProviderKqlQuery, + updateDataProviderType: timelineActions.updateDataProviderType, updateHighlightedDropAndProviderId: timelineActions.updateHighlightedDropAndProviderId, updateItemsPerPage: timelineActions.updateItemsPerPage, updateItemsPerPageOptions: timelineActions.updateItemsPerPageOptions, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx index 7b5e9c0c4c949..452808e51c096 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx @@ -119,22 +119,32 @@ Description.displayName = 'Description'; interface NameProps { timelineId: string; + timelineType: TimelineType; title: string; updateTitle: UpdateTitle; } -export const Name = React.memo(({ timelineId, title, updateTitle }) => ( - - updateTitle({ id: timelineId, title: e.target.value })} - placeholder={i18n.UNTITLED_TIMELINE} - spellCheck={true} - value={title} - /> - -)); +export const Name = React.memo(({ timelineId, timelineType, title, updateTitle }) => { + const handleChange = useCallback((e) => updateTitle({ id: timelineId, title: e.target.value }), [ + timelineId, + updateTitle, + ]); + + return ( + + + + ); +}); Name.displayName = 'Name'; interface NewCaseProps { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx index 3a28c26a16c9a..ce99304c676ee 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx @@ -6,6 +6,7 @@ import { mount } from 'enzyme'; import React from 'react'; + import { TimelineStatus, TimelineType } from '../../../../../common/types/timeline'; import { mockGlobalState, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx index b3567151c74b3..6de40725f461c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx @@ -27,15 +27,6 @@ import { useKibana } from '../../../../common/lib/kibana'; import { APP_ID } from '../../../../../common/constants'; import { getCaseDetailsUrl } from '../../../../common/components/link_to'; -type CreateTimeline = ({ - id, - show, - timelineType, -}: { - id: string; - show?: boolean; - timelineType?: TimelineTypeLiteral; -}) => void; type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void; type UpdateTitle = ({ id, title }: { id: string; title: string }) => void; type UpdateDescription = ({ id, description }: { id: string; description: string }) => void; @@ -43,7 +34,6 @@ type ToggleLock = ({ linkToId }: { linkToId: InputsModelId }) => void; interface Props { associateNote: AssociateNote; - createTimeline: CreateTimeline; description: string; getNotesByIds: (noteIds: string[]) => Note[]; graphEventId?: string; @@ -78,7 +68,6 @@ const settingsWidth = 55; export const Properties = React.memo( ({ associateNote, - createTimeline, description, getNotesByIds, graphEventId, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx index 4673ba662b2e9..a3cd8802c36bc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx @@ -13,7 +13,6 @@ import { AssociateNote, UpdateNote } from '../../notes/helpers'; import { Note } from '../../../../common/lib/note'; import { SuperDatePicker } from '../../../../common/components/super_date_picker'; - import { TimelineTypeLiteral, TimelineStatusLiteral } from '../../../../../common/types/timeline'; import * as i18n from './translations'; @@ -106,7 +105,12 @@ export const PropertiesLeft = React.memo( /> - + {showDescription ? ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.test.tsx index a36e841f3f871..3f02772b46bb3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.test.tsx @@ -10,7 +10,6 @@ import React from 'react'; import { PropertiesRight } from './properties_right'; import { useKibana } from '../../../../common/lib/kibana'; import { TimelineStatus, TimelineType } from '../../../../../common/types/timeline'; -import { disableTemplate } from '../../../../../common/constants'; jest.mock('../../../../common/lib/kibana', () => { return { @@ -97,20 +96,10 @@ describe('Properties Right', () => { expect(wrapper.find('[data-test-subj="settings-gear"]').exists()).toBeTruthy(); }); - test('it renders create timelin btn', () => { + test('it renders create timeline btn', () => { expect(wrapper.find('[data-test-subj="create-default-btn"]').exists()).toBeTruthy(); }); - /* - * CreateTemplateTimelineBtn - * Remove the comment here to enable CreateTemplateTimelineBtn - */ - test('it renders no create template timelin btn', () => { - expect(wrapper.find('[data-test-subj="create-template-btn"]').exists()).toEqual( - !disableTemplate - ); - }); - test('it renders create attach timeline to a case btn', () => { expect(wrapper.find('[data-test-subj="NewCase"]').exists()).toBeTruthy(); }); @@ -208,14 +197,8 @@ describe('Properties Right', () => { expect(wrapper.find('[data-test-subj="settings-gear"]').exists()).toBeTruthy(); }); - test('it renders no create timelin btn', () => { - expect(wrapper.find('[data-test-subj="create-default-btn"]').exists()).not.toBeTruthy(); - }); - - test('it renders create template timelin btn if it is enabled', () => { - expect(wrapper.find('[data-test-subj="create-template-btn"]').exists()).toEqual( - !disableTemplate - ); + test('it renders create timeline template btn', () => { + expect(wrapper.find('[data-test-subj="create-template-btn"]').exists()).toEqual(true); }); test('it renders create attach timeline to a case btn', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx index 8a1bf0a842cb0..70257c97a6887 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx @@ -16,9 +16,11 @@ import { } from '@elastic/eui'; import { NewTimeline, Description, NotesButton, NewCase, ExistingCase } from './helpers'; -import { disableTemplate } from '../../../../../common/constants'; -import { TimelineStatusLiteral, TimelineTypeLiteral } from '../../../../../common/types/timeline'; - +import { + TimelineStatusLiteral, + TimelineTypeLiteral, + TimelineType, +} from '../../../../../common/types/timeline'; import { InspectButton, InspectButtonContainer } from '../../../../common/components/inspect'; import { useKibana } from '../../../../common/lib/kibana'; import { Note } from '../../../../common/lib/note'; @@ -151,41 +153,39 @@ const PropertiesRightComponent: React.FC = ({ )} - {/* - * CreateTemplateTimelineBtn - * Remove the comment here to enable CreateTemplateTimelineBtn - */} - {!disableTemplate && ( - - - - )} - - - - - - + - + + {timelineType === TimelineType.default && ( + <> + + + + + + + + )} + ( updateEventType, updateKqlMode, updateReduxTime, - }) => ( - <> - - - - - updateKqlMode({ id: timelineId, kqlMode: mode })} - options={options} - popoverClassName={searchOrFilterPopoverClassName} - valueOfSelected={kqlMode} + }) => { + const handleChange = useCallback( + (mode: KqlMode) => updateKqlMode({ id: timelineId, kqlMode: mode }), + [timelineId, updateKqlMode] + ); + + return ( + <> + + + + + + + + + - - - - - - - - - - - - - ) + + + + + + + + + ); + } ); SearchOrFilter.displayName = 'SearchOrFilter'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_super_select/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_super_select/index.tsx index b549fdab8ea4a..825d4fe3b29b1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_super_select/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_super_select/index.tsx @@ -52,7 +52,7 @@ const SearchTimelineSuperSelectComponent: React.FC { const [isPopoverOpen, setIsPopoverOpen] = useState(false); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.test.tsx index 0ff4c0a70fff2..6bea5a7b7635e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.test.tsx @@ -60,7 +60,7 @@ describe('SelectableTimeline', () => { }); }); - describe('template timeline', () => { + describe('timeline template', () => { const templateTimelineProps = { ...props, timelineType: TimelineType.template }; beforeAll(() => { wrapper = shallow(); @@ -74,7 +74,7 @@ describe('SelectableTimeline', () => { const searchProps: SearchProps = wrapper .find('[data-test-subj="selectable-input"]') .prop('searchProps'); - expect(searchProps.placeholder).toEqual('e.g. Template timeline name or description'); + expect(searchProps.placeholder).toEqual('e.g. Timeline template name or description'); }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx index dacaf325130d7..ae8bf53090789 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx @@ -33,7 +33,6 @@ import * as i18nTimeline from '../../open_timeline/translations'; import { OpenTimelineResult } from '../../open_timeline/types'; import { getEmptyTagValue } from '../../../../common/components/empty_value'; import * as i18n from '../translations'; -import { useTimelineStatus } from '../../open_timeline/use_timeline_status'; const MyEuiFlexItem = styled(EuiFlexItem)` display: inline-block; @@ -119,7 +118,6 @@ const SelectableTimelineComponent: React.FC = ({ const [onlyFavorites, setOnlyFavorites] = useState(false); const [searchRef, setSearchRef] = useState(null); const { fetchAllTimeline, timelines, loading, totalCount: timelineCount } = useGetAllTimeline(); - const { timelineStatus, templateTimelineType } = useTimelineStatus({ timelineType }); const onSearchTimeline = useCallback((val) => { setSearchTimelineValue(val); @@ -263,19 +261,11 @@ const SelectableTimelineComponent: React.FC = ({ sortOrder: Direction.desc, }, onlyUserFavorite: onlyFavorites, - status: timelineStatus, + status: null, timelineType, - templateTimelineType, + templateTimelineType: null, }); - }, [ - fetchAllTimeline, - onlyFavorites, - pageSize, - searchTimelineValue, - timelineType, - timelineStatus, - templateTimelineType, - ]); + }, [fetchAllTimeline, onlyFavorites, pageSize, searchTimelineValue, timelineType]); return ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx index b58505546c341..360737ce41d2d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx @@ -24,7 +24,7 @@ import { TimelineComponent, Props as TimelineComponentProps } from './timeline'; import { Sort } from './body/sort'; import { mockDataProviders } from './data_providers/mock/mock_data_providers'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; -import { TimelineStatus } from '../../../../common/types/timeline'; +import { TimelineStatus, TimelineType } from '../../../../common/types/timeline'; jest.mock('../../../common/lib/kibana'); jest.mock('./properties/properties_right'); @@ -82,6 +82,7 @@ describe('Timeline', () => { indexPattern, indexToAdd: [], isLive: false, + isSaving: false, itemsPerPage: 5, itemsPerPageOptions: [5, 10, 20], kqlMode: 'search' as TimelineComponentProps['kqlMode'], @@ -93,6 +94,7 @@ describe('Timeline', () => { onDataProviderRemoved: jest.fn(), onToggleDataProviderEnabled: jest.fn(), onToggleDataProviderExcluded: jest.fn(), + onToggleDataProviderType: jest.fn(), show: true, showCallOutUnauthorizedMsg: false, start: startDate, @@ -100,6 +102,7 @@ describe('Timeline', () => { status: TimelineStatus.active, toggleColumn: jest.fn(), usersViewing: ['elastic'], + timelineType: TimelineType.default, }; }); @@ -298,9 +301,9 @@ describe('Timeline', () => { ); const andProviderBadgesText = andProviderBadges.map((node) => node.text()).join(' '); - expect(andProviderBadges.length).toEqual(6); + expect(andProviderBadges.length).toEqual(3); expect(andProviderBadgesText).toEqual( - 'name: "Provider 1" name: "Provider 2" name: "Provider 3"' + 'name: "Provider 1" name: "Provider 2" name: "Provider 3"' ); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx index b930325c3d35d..ee48f97164b86 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlyoutHeader, EuiFlyoutBody, EuiFlyoutFooter } from '@elastic/eui'; +import { EuiFlyoutHeader, EuiFlyoutBody, EuiFlyoutFooter, EuiProgress } from '@elastic/eui'; import { getOr, isEmpty } from 'lodash/fp'; import React, { useState, useMemo, useEffect } from 'react'; import { useDispatch } from 'react-redux'; @@ -27,12 +27,14 @@ import { OnDataProviderEdited, OnToggleDataProviderEnabled, OnToggleDataProviderExcluded, + OnToggleDataProviderType, } from './events'; import { TimelineKqlFetch } from './fetch_kql_timeline'; import { Footer, footerHeight } from './footer'; import { TimelineHeader } from './header'; import { combineQueries } from './helpers'; import { TimelineRefetch } from './refetch_timeline'; +import { TIMELINE_TEMPLATE } from './translations'; import { esQuery, Filter, @@ -40,12 +42,13 @@ import { IIndexPattern, } from '../../../../../../../src/plugins/data/public'; import { useManageTimeline } from '../manage_timeline'; -import { TimelineStatusLiteral } from '../../../../common/types/timeline'; +import { TimelineType, TimelineStatusLiteral } from '../../../../common/types/timeline'; const TimelineContainer = styled.div` height: 100%; display: flex; flex-direction: column; + position: relative; `; const TimelineHeaderContainer = styled.div` @@ -84,6 +87,13 @@ const StyledEuiFlyoutFooter = styled(EuiFlyoutFooter)` padding: 0 10px 5px 12px; `; +const TimelineTemplateBadge = styled.div` + background: ${({ theme }) => theme.eui.euiColorVis3_behindText}; + color: #fff; + padding: 10px 15px; + font-size: 0.8em; +`; + export interface Props { browserFields: BrowserFields; columns: ColumnHeaderOptions[]; @@ -96,6 +106,7 @@ export interface Props { indexPattern: IIndexPattern; indexToAdd: string[]; isLive: boolean; + isSaving: boolean; itemsPerPage: number; itemsPerPageOptions: number[]; kqlMode: KqlMode; @@ -107,6 +118,7 @@ export interface Props { onDataProviderRemoved: OnDataProviderRemoved; onToggleDataProviderEnabled: OnToggleDataProviderEnabled; onToggleDataProviderExcluded: OnToggleDataProviderExcluded; + onToggleDataProviderType: OnToggleDataProviderType; show: boolean; showCallOutUnauthorizedMsg: boolean; start: number; @@ -114,6 +126,7 @@ export interface Props { status: TimelineStatusLiteral; toggleColumn: (column: ColumnHeaderOptions) => void; usersViewing: string[]; + timelineType: TimelineType; } /** The parent Timeline component */ @@ -129,6 +142,7 @@ export const TimelineComponent: React.FC = ({ indexPattern, indexToAdd, isLive, + isSaving, itemsPerPage, itemsPerPageOptions, kqlMode, @@ -140,11 +154,13 @@ export const TimelineComponent: React.FC = ({ onDataProviderRemoved, onToggleDataProviderEnabled, onToggleDataProviderExcluded, + onToggleDataProviderType, show, showCallOutUnauthorizedMsg, start, status, sort, + timelineType, toggleColumn, usersViewing, }) => { @@ -182,6 +198,7 @@ export const TimelineComponent: React.FC = ({ }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { setIsTimelineLoading({ id, isLoading: isQueryLoading || loadingIndexName }); }, [loadingIndexName, id, isQueryLoading, setIsTimelineLoading]); @@ -192,6 +209,10 @@ export const TimelineComponent: React.FC = ({ return ( + {isSaving && } + {timelineType === TimelineType.template && ( + {TIMELINE_TEMPLATE} + )} = ({ = ({ onDataProviderRemoved={onDataProviderRemoved} onToggleDataProviderEnabled={onToggleDataProviderEnabled} onToggleDataProviderExcluded={onToggleDataProviderExcluded} + onToggleDataProviderType={onToggleDataProviderType} show={show} showCallOutUnauthorizedMsg={showCallOutUnauthorizedMsg} + timelineId={id} status={status} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/translations.ts index ebd27f9bffa5e..f8c38b3527d7a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/translations.ts @@ -23,7 +23,7 @@ export const DEFAULT_TIMELINE_DESCRIPTION = i18n.translate( export const SEARCH_BOX_TIMELINE_PLACEHOLDER = (timelineType: TimelineTypeLiteral) => i18n.translate('xpack.securitySolution.timeline.searchBoxPlaceholder', { - values: { timeline: timelineType === TimelineType.template ? 'Template timeline' : 'Timeline' }, + values: { timeline: timelineType === TimelineType.template ? 'Timeline template' : 'Timeline' }, defaultMessage: 'e.g. {timeline} name or description', }); @@ -33,3 +33,10 @@ export const INSERT_TIMELINE = i18n.translate( defaultMessage: 'Insert timeline link', } ); + +export const TIMELINE_TEMPLATE = i18n.translate( + 'xpack.securitySolution.timeline.flyoutTimelineTemplateLabel', + { + defaultMessage: 'Timeline template', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx index 17cc0f64de039..4ecabeef16dff 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx @@ -23,6 +23,7 @@ import { useApolloClient } from '../../../common/utils/apollo_context'; import { allTimelinesQuery } from './index.gql_query'; import * as i18n from '../../pages/translations'; import { + TimelineType, TimelineTypeLiteralWithNull, TimelineStatusLiteralWithNull, TemplateTimelineTypeLiteralWithNull, @@ -92,6 +93,7 @@ export const getAllTimeline = memoizeOne( title: timeline.title, updated: timeline.updated, updatedBy: timeline.updatedBy, + timelineType: timeline.timelineType ?? TimelineType.default, })) ); diff --git a/x-pack/plugins/security_solution/public/timelines/containers/one/index.gql_query.ts b/x-pack/plugins/security_solution/public/timelines/containers/one/index.gql_query.ts index 47e80b005fb99..24beed0801aa6 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/one/index.gql_query.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/one/index.gql_query.ts @@ -28,6 +28,7 @@ export const oneTimelineQuery = gql` enabled excluded kqlQuery + type queryMatch { field displayField diff --git a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.test.tsx b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.test.tsx index 1bd5874394df3..2e59dbb72233f 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.test.tsx @@ -9,12 +9,23 @@ import React from 'react'; import { useKibana } from '../../common/lib/kibana'; import { TimelinesPageComponent } from './timelines_page'; -import { disableTemplate } from '../../../common/constants'; -jest.mock('../../overview/components/events_by_dataset'); +jest.mock('react-router-dom', () => { + const originalModule = jest.requireActual('react-router-dom'); + return { + ...originalModule, + useParams: jest.fn().mockReturnValue({ + tabName: 'default', + }), + }; +}); +jest.mock('../../overview/components/events_by_dataset'); jest.mock('../../common/lib/kibana', () => { + const originalModule = jest.requireActual('../../common/lib/kibana'); + return { + ...originalModule, useKibana: jest.fn(), }; }); @@ -59,22 +70,16 @@ describe('TimelinesPageComponent', () => { ).toEqual(true); }); - test('it renders create timelin btn', () => { + test('it renders create timeline btn', () => { expect(wrapper.find('[data-test-subj="create-default-btn"]').exists()).toBeTruthy(); }); - /* - * CreateTemplateTimelineBtn - * Remove the comment here to enable CreateTemplateTimelineBtn - */ - test('it renders no create template timelin btn', () => { - expect(wrapper.find('[data-test-subj="create-template-btn"]').exists()).toEqual( - !disableTemplate - ); + test('it renders no create timeline template btn', () => { + expect(wrapper.find('[data-test-subj="create-template-btn"]').exists()).toBeFalsy(); }); }); - describe('If the user is not authorised', () => { + describe('If the user is not authorized', () => { beforeAll(() => { ((useKibana as unknown) as jest.Mock).mockReturnValue({ services: { diff --git a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx index 089a928403b0b..56aff3ec8aaac 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx +++ b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx @@ -7,9 +7,9 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React, { useCallback, useState } from 'react'; import styled from 'styled-components'; +import { useParams } from 'react-router-dom'; -import { disableTemplate } from '../../../common/constants'; - +import { TimelineType } from '../../../common/types/timeline'; import { HeaderPage } from '../../common/components/header_page'; import { WrapperPage } from '../../common/components/wrapper_page'; import { useKibana } from '../../common/lib/kibana'; @@ -31,6 +31,7 @@ const TimelinesContainer = styled.div` export const DEFAULT_SEARCH_RESULTS_PER_PAGE = 10; export const TimelinesPageComponent: React.FC = () => { + const { tabName } = useParams(); const [importDataModalToggle, setImportDataModalToggle] = useState(false); const onImportTimelineBtnClick = useCallback(() => { setImportDataModalToggle(true); @@ -56,20 +57,17 @@ export const TimelinesPageComponent: React.FC = () => { )} - - {capabilitiesCanUserCRUD && ( - - )} - - {/** - * CreateTemplateTimelineBtn - * Remove the comment here to enable CreateTemplateTimelineBtn - */} - {!disableTemplate && ( + {tabName === TimelineType.default ? ( + + {capabilitiesCanUserCRUD && ( + + )} + + ) : ( ('PROVIDER_EDIT_KQL_QUERY'); +export const updateDataProviderType = actionCreator<{ + andProviderId?: string; + id: string; + type: DataProviderType; + providerId: string; +}>('UPDATE_PROVIDER_TYPE'); + export const updateHighlightedDropAndProviderId = actionCreator<{ id: string; providerId: string; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts index 94acb9d92075b..605700cb71a2a 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts @@ -58,6 +58,7 @@ import { updateDataProviderEnabled, updateDataProviderExcluded, updateDataProviderKqlQuery, + updateDataProviderType, updateDescription, updateKqlMode, updateProviders, @@ -96,6 +97,7 @@ const timelineActionsType = [ updateDataProviderEnabled.type, updateDataProviderExcluded.type, updateDataProviderKqlQuery.type, + updateDataProviderType.type, updateDescription.type, updateEventType.type, updateKqlMode.type, diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx index 388869194085c..7d65181db65fd 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx @@ -39,7 +39,7 @@ import { Direction } from '../../../graphql/types'; import { addTimelineInStorage } from '../../containers/local_storage'; import { isPageTimeline } from './epic_local_storage'; -import { TimelineStatus } from '../../../../common/types/timeline'; +import { TimelineStatus, TimelineType } from '../../../../common/types/timeline'; jest.mock('../../containers/local_storage'); @@ -89,6 +89,7 @@ describe('epicLocalStorage', () => { indexPattern, indexToAdd: [], isLive: false, + isSaving: false, itemsPerPage: 5, itemsPerPageOptions: [5, 10, 20], kqlMode: 'search' as TimelineComponentProps['kqlMode'], @@ -100,11 +101,13 @@ describe('epicLocalStorage', () => { onDataProviderRemoved: jest.fn(), onToggleDataProviderEnabled: jest.fn(), onToggleDataProviderExcluded: jest.fn(), + onToggleDataProviderType: jest.fn(), show: true, showCallOutUnauthorizedMsg: false, start: startDate, status: TimelineStatus.active, sort, + timelineType: TimelineType.default, toggleColumn: jest.fn(), usersViewing: ['elastic'], }; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts index 33770aacde6bb..a347d3e41e206 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts @@ -9,14 +9,15 @@ import { getOr, omit, uniq, isEmpty, isEqualWith, union } from 'lodash/fp'; import uuid from 'uuid'; import { Filter } from '../../../../../../../src/plugins/data/public'; -import { disableTemplate } from '../../../../common/constants'; - import { getColumnWidthFromType } from '../../../timelines/components/timeline/body/column_headers/helpers'; import { Sort } from '../../../timelines/components/timeline/body/sort'; import { DataProvider, QueryOperator, QueryMatch, + DataProviderType, + IS_OPERATOR, + EXISTS_OPERATOR, } from '../../../timelines/components/timeline/data_providers/data_provider'; import { KueryFilterQuery, SerializedFilterQuery } from '../../../common/store/model'; import { TimelineNonEcsData } from '../../../graphql/types'; @@ -161,7 +162,7 @@ export const addNewTimeline = ({ timelineType, }: AddNewTimelineParams): TimelineById => { const templateTimelineInfo = - !disableTemplate && timelineType === TimelineType.template + timelineType === TimelineType.template ? { templateTimelineId: uuid.v4(), templateTimelineVersion: 1, @@ -186,7 +187,7 @@ export const addNewTimeline = ({ isLoading: false, showCheckboxes, showRowRenderers, - timelineType: !disableTemplate ? timelineType : timelineDefaults.timelineType, + timelineType, ...templateTimelineInfo, }, }; @@ -1046,6 +1047,92 @@ export const updateTimelineProviderKqlQuery = ({ }; }; +interface UpdateTimelineProviderTypeParams { + andProviderId?: string; + id: string; + providerId: string; + type: DataProviderType; + timelineById: TimelineById; +} + +const updateTypeAndProvider = ( + andProviderId: string, + type: DataProviderType, + providerId: string, + timeline: TimelineModel +) => + timeline.dataProviders.map((provider) => + provider.id === providerId + ? { + ...provider, + and: provider.and.map((andProvider) => + andProvider.id === andProviderId + ? { + ...andProvider, + type, + name: type === DataProviderType.template ? `${andProvider.queryMatch.field}` : '', + queryMatch: { + ...andProvider.queryMatch, + displayField: undefined, + displayValue: undefined, + value: + type === DataProviderType.template ? `{${andProvider.queryMatch.field}}` : '', + operator: (type === DataProviderType.template + ? IS_OPERATOR + : EXISTS_OPERATOR) as QueryOperator, + }, + } + : andProvider + ), + } + : provider + ); + +const updateTypeProvider = (type: DataProviderType, providerId: string, timeline: TimelineModel) => + timeline.dataProviders.map((provider) => + provider.id === providerId + ? { + ...provider, + type, + name: type === DataProviderType.template ? `${provider.queryMatch.field}` : '', + queryMatch: { + ...provider.queryMatch, + displayField: undefined, + displayValue: undefined, + value: type === DataProviderType.template ? `{${provider.queryMatch.field}}` : '', + operator: (type === DataProviderType.template + ? IS_OPERATOR + : EXISTS_OPERATOR) as QueryOperator, + }, + } + : provider + ); + +export const updateTimelineProviderType = ({ + andProviderId, + id, + providerId, + type, + timelineById, +}: UpdateTimelineProviderTypeParams): TimelineById => { + const timeline = timelineById[id]; + + if (timeline.timelineType !== TimelineType.template && type === DataProviderType.template) { + // Not supported, timeline template cannot have template type providers + return timelineById; + } + + return { + ...timelineById, + [id]: { + ...timeline, + dataProviders: andProviderId + ? updateTypeAndProvider(andProviderId, type, providerId, timeline) + : updateTypeProvider(type, providerId, timeline), + }, + }; +}; + interface UpdateTimelineItemsPerPageParams { id: string; itemsPerPage: number; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts index 57895fea8f8ff..a78fbc41ac430 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts @@ -87,9 +87,9 @@ export interface TimelineModel { title: string; /** timelineType: default | template */ timelineType: TimelineType; - /** an unique id for template timeline */ + /** an unique id for timeline template */ templateTimelineId: string | null; - /** null for default timeline, number for template timeline */ + /** null for default timeline, number for timeline template */ templateTimelineVersion: number | null; /** Notes added to the timeline itself. Notes added to events are stored (separately) in `eventIdToNote` */ noteIds: string[]; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts index 6e7a36079a0c3..b8bdb4f2ad7f0 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts @@ -11,6 +11,7 @@ import { TimelineType, TimelineStatus } from '../../../../common/types/timeline' import { IS_OPERATOR, DataProvider, + DataProviderType, DataProvidersAnd, } from '../../../timelines/components/timeline/data_providers/data_provider'; import { defaultColumnHeaderType } from '../../../timelines/components/timeline/body/column_headers/default_headers'; @@ -35,6 +36,7 @@ import { updateTimelinePerPageOptions, updateTimelineProviderEnabled, updateTimelineProviderExcluded, + updateTimelineProviderType, updateTimelineProviders, updateTimelineRange, updateTimelineShowTimeline, @@ -107,6 +109,14 @@ const timelineByIdMock: TimelineById = { }, }; +const timelineByIdTemplateMock: TimelineById = { + ...timelineByIdMock, + foo: { + ...timelineByIdMock.foo, + timelineType: TimelineType.template, + }, +}; + const columnsMock: ColumnHeaderOptions[] = [ defaultHeaders[0], defaultHeaders[1], @@ -1547,6 +1557,211 @@ describe('Timeline', () => { }); }); + describe('#updateTimelineProviderType', () => { + test('should return the same reference if run on timelineType default', () => { + const update = updateTimelineProviderType({ + id: 'foo', + providerId: '123', + type: DataProviderType.template, // value we are updating from default to template + timelineById: timelineByIdMock, + }); + expect(update).toBe(timelineByIdMock); + }); + + test('should return a new reference and not the same reference', () => { + const update = updateTimelineProviderType({ + id: 'foo', + providerId: '123', + type: DataProviderType.template, // value we are updating from default to template + timelineById: timelineByIdTemplateMock, + }); + expect(update).not.toBe(timelineByIdTemplateMock); + }); + + test('should return a new reference for data provider and not the same reference of data provider', () => { + const update = updateTimelineProviderType({ + id: 'foo', + providerId: '123', + type: DataProviderType.template, // value we are updating from default to template + timelineById: timelineByIdTemplateMock, + }); + expect(update.foo.dataProviders).not.toBe(timelineByIdTemplateMock.foo.dataProviders); + }); + + test('should update the timeline provider type from default to template', () => { + const update = updateTimelineProviderType({ + id: 'foo', + providerId: '123', + type: DataProviderType.template, // value we are updating from default to template + timelineById: timelineByIdTemplateMock, + }); + const expected: TimelineById = { + foo: { + id: 'foo', + savedObjectId: null, + columns: [], + dataProviders: [ + { + and: [], + id: '123', + name: '', // This value changed + enabled: true, + excluded: false, + kqlQuery: '', + type: DataProviderType.template, // value we are updating from default to template + queryMatch: { + field: '', + value: '{}', // This value changed + operator: IS_OPERATOR, + }, + }, + ], + description: '', + deletedEventIds: [], + eventIdToNoteIds: {}, + highlightedDropAndProviderId: '', + historyIds: [], + isFavorite: false, + isLive: false, + isSelectAllChecked: false, + isLoading: false, + kqlMode: 'filter', + kqlQuery: { filterQuery: null, filterQueryDraft: null }, + loadingEventIds: [], + title: '', + timelineType: TimelineType.template, + templateTimelineVersion: null, + templateTimelineId: null, + noteIds: [], + dateRange: { + start: 0, + end: 0, + }, + selectedEventIds: {}, + show: true, + showRowRenderers: true, + showCheckboxes: false, + sort: { + columnId: '@timestamp', + sortDirection: Direction.desc, + }, + status: TimelineStatus.active, + pinnedEventIds: {}, + pinnedEventsSaveObject: {}, + itemsPerPage: 25, + itemsPerPageOptions: [10, 25, 50], + width: DEFAULT_TIMELINE_WIDTH, + isSaving: false, + version: null, + }, + }; + expect(update).toEqual(expected); + }); + + test('should update only one data provider and not two data providers', () => { + const multiDataProvider = timelineByIdTemplateMock.foo.dataProviders.concat({ + and: [], + id: '456', + name: 'data provider 1', + enabled: true, + excluded: false, + type: DataProviderType.template, + kqlQuery: '', + queryMatch: { + field: '', + value: '', + operator: IS_OPERATOR, + }, + }); + const multiDataProviderMock = set( + 'foo.dataProviders', + multiDataProvider, + timelineByIdTemplateMock + ); + const update = updateTimelineProviderType({ + id: 'foo', + providerId: '123', + type: DataProviderType.template, // value we are updating from default to template + timelineById: multiDataProviderMock, + }); + const expected: TimelineById = { + foo: { + id: 'foo', + savedObjectId: null, + columns: [], + dataProviders: [ + { + and: [], + id: '123', + name: '', + enabled: true, + excluded: false, + type: DataProviderType.template, // value we are updating from default to template + kqlQuery: '', + queryMatch: { + field: '', + value: '{}', + operator: IS_OPERATOR, + }, + }, + { + and: [], + id: '456', + name: 'data provider 1', + enabled: true, + excluded: false, + kqlQuery: '', + queryMatch: { + field: '', + value: '', + operator: IS_OPERATOR, + }, + type: DataProviderType.template, + }, + ], + description: '', + deletedEventIds: [], + eventIdToNoteIds: {}, + highlightedDropAndProviderId: '', + historyIds: [], + isFavorite: false, + isLive: false, + isSelectAllChecked: false, + isLoading: false, + kqlMode: 'filter', + kqlQuery: { filterQuery: null, filterQueryDraft: null }, + loadingEventIds: [], + title: '', + timelineType: TimelineType.template, + templateTimelineId: null, + templateTimelineVersion: null, + noteIds: [], + dateRange: { + start: 0, + end: 0, + }, + selectedEventIds: {}, + show: true, + showRowRenderers: true, + showCheckboxes: false, + sort: { + columnId: '@timestamp', + sortDirection: Direction.desc, + }, + status: TimelineStatus.active, + pinnedEventIds: {}, + pinnedEventsSaveObject: {}, + itemsPerPage: 25, + itemsPerPageOptions: [10, 25, 50], + width: DEFAULT_TIMELINE_WIDTH, + isSaving: false, + version: null, + }, + }; + expect(update).toEqual(expected); + }); + }); + describe('#updateTimelineAndProviderExcluded', () => { let timelineByIdwithAndMock: TimelineById = timelineByIdMock; beforeEach(() => { diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts index 4072b4ac2f78b..6bb546c16b617 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts @@ -39,6 +39,7 @@ import { updateDataProviderEnabled, updateDataProviderExcluded, updateDataProviderKqlQuery, + updateDataProviderType, updateDescription, updateEventType, updateHighlightedDropAndProviderId, @@ -88,6 +89,7 @@ import { updateTimelineProviderExcluded, updateTimelineProviderProperties, updateTimelineProviderKqlQuery, + updateTimelineProviderType, updateTimelineProviders, updateTimelineRange, updateTimelineShowTimeline, @@ -427,7 +429,16 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) }), }) ) - + .case(updateDataProviderType, (state, { id, type, providerId, andProviderId }) => ({ + ...state, + timelineById: updateTimelineProviderType({ + id, + type, + providerId, + timelineById: state.timelineById, + andProviderId, + }), + })) .case(updateDataProviderKqlQuery, (state, { id, kqlQuery, providerId }) => ({ ...state, timelineById: updateTimelineProviderKqlQuery({ diff --git a/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts b/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts index a9d07389797db..e46d3be44dbd1 100644 --- a/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts +++ b/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts @@ -84,6 +84,12 @@ export const timelineSchema = gql` kqlQuery: String queryMatch: QueryMatchInput and: [DataProviderInput!] + type: DataProviderType + } + + enum DataProviderType { + default + template } input KueryFilterQueryInput { @@ -194,6 +200,7 @@ export const timelineSchema = gql` excluded: Boolean kqlQuery: String queryMatch: QueryMatchResult + type: DataProviderType and: [DataProviderResult!] } diff --git a/x-pack/plugins/security_solution/server/graphql/types.ts b/x-pack/plugins/security_solution/server/graphql/types.ts index a702b1a72f0a9..52bb4a9862160 100644 --- a/x-pack/plugins/security_solution/server/graphql/types.ts +++ b/x-pack/plugins/security_solution/server/graphql/types.ts @@ -187,6 +187,8 @@ export interface DataProviderInput { queryMatch?: Maybe; and?: Maybe; + + type?: Maybe; } export interface QueryMatchInput { @@ -344,6 +346,11 @@ export enum TlsFields { _id = '_id', } +export enum DataProviderType { + default = 'default', + template = 'template', +} + export enum TimelineStatus { active = 'active', draft = 'draft', @@ -2032,6 +2039,8 @@ export interface DataProviderResult { queryMatch?: Maybe; + type?: Maybe; + and?: Maybe; } @@ -8368,6 +8377,8 @@ export namespace DataProviderResultResolvers { queryMatch?: QueryMatchResolver, TypeParent, TContext>; + type?: TypeResolver, TypeParent, TContext>; + and?: AndResolver, TypeParent, TContext>; } @@ -8401,6 +8412,11 @@ export namespace DataProviderResultResolvers { Parent = DataProviderResult, TContext = SiemContext > = Resolver; + export type TypeResolver< + R = Maybe, + Parent = DataProviderResult, + TContext = SiemContext + > = Resolver; export type AndResolver< R = Maybe, Parent = DataProviderResult, diff --git a/x-pack/plugins/security_solution/server/lib/timeline/pick_saved_timeline.ts b/x-pack/plugins/security_solution/server/lib/timeline/pick_saved_timeline.ts index 68e7f8d5e6fe1..eb8f6f5022985 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/pick_saved_timeline.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/pick_saved_timeline.ts @@ -35,6 +35,7 @@ export const pickSavedTimeline = ( if (savedTimeline.timelineType === TimelineType.default) { savedTimeline.timelineType = savedTimeline.timelineType ?? TimelineType.default; + savedTimeline.status = savedTimeline.status ?? TimelineStatus.active; savedTimeline.templateTimelineId = null; savedTimeline.templateTimelineVersion = null; } diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/create_timelines_route.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/create_timelines_route.test.ts index 84a18cb1573dd..0286ef558810e 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/create_timelines_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/create_timelines_route.test.ts @@ -167,8 +167,8 @@ describe('create timelines', () => { }); }); - describe('Manipulate template timeline', () => { - describe('Create a new template timeline', () => { + describe('Manipulate timeline template', () => { + describe('Create a new timeline template', () => { beforeEach(async () => { jest.doMock('../saved_object', () => { return { @@ -199,19 +199,19 @@ describe('create timelines', () => { await server.inject(mockRequest, context); }); - test('should Create a new template timeline savedObject', async () => { + test('should Create a new timeline template savedObject', async () => { expect(mockPersistTimeline).toHaveBeenCalled(); }); - test('should Create a new template timeline savedObject without timelineId', async () => { + test('should Create a new timeline template savedObject without timelineId', async () => { expect(mockPersistTimeline.mock.calls[0][1]).toBeNull(); }); - test('should Create a new template timeline savedObject without template timeline version', async () => { + test('should Create a new timeline template savedObject without timeline template version', async () => { expect(mockPersistTimeline.mock.calls[0][2]).toBeNull(); }); - test('should Create a new template timeline savedObject witn given template timeline', async () => { + test('should Create a new timeline template savedObject witn given timeline template', async () => { expect(mockPersistTimeline.mock.calls[0][3]).toEqual( createTemplateTimelineWithTimelineId.timeline ); @@ -234,7 +234,7 @@ describe('create timelines', () => { }); }); - describe('Create a template timeline already exist', () => { + describe('Create a timeline template already exist', () => { beforeEach(() => { jest.doMock('../saved_object', () => { return { diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.test.ts index 15fb8f3411cfa..248bf358064c0 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.test.ts @@ -409,7 +409,7 @@ describe('import timelines', () => { }); }); -describe('import template timelines', () => { +describe('import timeline templates', () => { let server: ReturnType; let request: ReturnType; let securitySetup: SecurityPluginSetup; @@ -473,7 +473,7 @@ describe('import template timelines', () => { })); }); - describe('Import a new template timeline', () => { + describe('Import a new timeline template', () => { beforeEach(() => { jest.doMock('../saved_object', () => { return { @@ -596,7 +596,7 @@ describe('import template timelines', () => { }); }); - describe('Import a template timeline already exist', () => { + describe('Import a timeline template already exist', () => { beforeEach(() => { jest.doMock('../saved_object', () => { return { @@ -704,7 +704,7 @@ describe('import template timelines', () => { expect(response.status).toEqual(200); }); - test('should throw error if with given template timeline version conflict', async () => { + test('should throw error if with given timeline template version conflict', async () => { mockGetTupleDuplicateErrorsAndUniqueTimeline.mockReturnValue([ mockDuplicateIdErrors, [ diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.ts index 0f4e8f3204e2b..56e4e81b4214b 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.ts @@ -158,7 +158,7 @@ export const importTimelinesRoute = ( await compareTimelinesStatus.init(); const isTemplateTimeline = compareTimelinesStatus.isHandlingTemplateTimeline; if (compareTimelinesStatus.isCreatableViaImport) { - // create timeline / template timeline + // create timeline / timeline template newTimeline = await createTimelines({ frameworkRequest, timeline: { @@ -199,7 +199,7 @@ export const importTimelinesRoute = ( ); } else { if (compareTimelinesStatus.isUpdatableViaImport) { - // update template timeline + // update timeline template newTimeline = await createTimelines({ frameworkRequest, timeline: parsedTimelineObject, diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.test.ts index 3cedb925649a2..17e6e8a84ef22 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.test.ts @@ -168,8 +168,8 @@ describe('update timelines', () => { }); }); - describe('Manipulate template timeline', () => { - describe('Update an existing template timeline', () => { + describe('Manipulate timeline template', () => { + describe('Update an existing timeline template', () => { beforeEach(async () => { jest.doMock('../saved_object', () => { return { @@ -209,25 +209,25 @@ describe('update timelines', () => { ); }); - test('should Update existing template timeline with template timelineId', async () => { + test('should Update existing timeline template with timeline templateId', async () => { expect(mockGetTemplateTimeline.mock.calls[0][1]).toEqual( updateTemplateTimelineWithTimelineId.timeline.templateTimelineId ); }); - test('should Update existing template timeline with timelineId', async () => { + test('should Update existing timeline template with timelineId', async () => { expect(mockPersistTimeline.mock.calls[0][1]).toEqual( updateTemplateTimelineWithTimelineId.timelineId ); }); - test('should Update existing template timeline with timeline version', async () => { + test('should Update existing timeline template with timeline version', async () => { expect(mockPersistTimeline.mock.calls[0][2]).toEqual( updateTemplateTimelineWithTimelineId.version ); }); - test('should Update existing template timeline witn given timeline', async () => { + test('should Update existing timeline template witn given timeline', async () => { expect(mockPersistTimeline.mock.calls[0][3]).toEqual( updateTemplateTimelineWithTimelineId.timeline ); @@ -241,7 +241,7 @@ describe('update timelines', () => { expect(mockPersistNote).not.toBeCalled(); }); - test('returns 200 when create template timeline successfully', async () => { + test('returns 200 when create timeline template successfully', async () => { const response = await server.inject( getUpdateTimelinesRequest(updateTemplateTimelineWithTimelineId), context @@ -250,7 +250,7 @@ describe('update timelines', () => { }); }); - describe("Update a template timeline that doesn't exist", () => { + describe("Update a timeline template that doesn't exist", () => { beforeEach(() => { jest.doMock('../saved_object', () => { return { diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/compare_timelines_status.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/compare_timelines_status.test.ts index a6d379e534bc2..6e3e3a420963f 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/compare_timelines_status.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/compare_timelines_status.test.ts @@ -179,8 +179,8 @@ describe('CompareTimelinesStatus', () => { }); }); - describe('template timeline', () => { - describe('given template timeline exists', () => { + describe('timeline template', () => { + describe('given timeline template exists', () => { const mockGetTimeline: jest.Mock = jest.fn(); const mockGetTemplateTimeline: jest.Mock = jest.fn(); @@ -249,12 +249,12 @@ describe('CompareTimelinesStatus', () => { expect(timelineObj.isUpdatableViaImport).toEqual(true); }); - test('should indicate we are handling a template timeline', () => { + test('should indicate we are handling a timeline template', () => { expect(timelineObj.isHandlingTemplateTimeline).toEqual(true); }); }); - describe('given template timeline does NOT exists', () => { + describe('given timeline template does NOT exists', () => { const mockGetTimeline: jest.Mock = jest.fn(); const mockGetTemplateTimeline: jest.Mock = jest.fn(); @@ -339,7 +339,7 @@ describe('CompareTimelinesStatus', () => { expect(error?.body).toEqual(UPDATE_TEMPLATE_TIMELINE_ERROR_MESSAGE); }); - test('should indicate we are handling a template timeline', () => { + test('should indicate we are handling a timeline template', () => { expect(timelineObj.isHandlingTemplateTimeline).toEqual(true); }); }); @@ -427,7 +427,7 @@ describe('CompareTimelinesStatus', () => { }); }); - describe('template timeline', () => { + describe('timeline template', () => { const mockGetTimeline: jest.Mock = jest.fn(); const mockGetTemplateTimeline: jest.Mock = jest.fn(); let timelineObj: TimelinesStatusType; @@ -589,7 +589,7 @@ describe('CompareTimelinesStatus', () => { }); }); - describe('immutable template timeline', () => { + describe('immutable timeline template', () => { const mockGetTimeline: jest.Mock = jest.fn(); const mockGetTemplateTimeline: jest.Mock = jest.fn(); let timelineObj: TimelinesStatusType; @@ -662,7 +662,7 @@ describe('CompareTimelinesStatus', () => { }); }); - describe('If create template timeline without template timeline id', () => { + describe('If create timeline template without timeline template id', () => { const mockGetTimeline: jest.Mock = jest.fn(); const mockGetTemplateTimeline: jest.Mock = jest.fn(); @@ -724,7 +724,7 @@ describe('CompareTimelinesStatus', () => { }); }); - describe('Throw error if template timeline version is conflict when update via import', () => { + describe('Throw error if timeline template version is conflict when update via import', () => { const mockGetTimeline: jest.Mock = jest.fn(); const mockGetTemplateTimeline: jest.Mock = jest.fn(); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/failure_cases.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/failure_cases.ts index 5e7a73ca18d0e..d41e8fc190983 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/failure_cases.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/failure_cases.ts @@ -3,6 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import { isEmpty } from 'lodash/fp'; import { TimelineSavedObject, @@ -85,8 +86,8 @@ const commonUpdateTemplateTimelineCheck = ( } if (existTemplateTimeline == null && templateTimelineVersion != null) { - // template timeline !exists - // Throw error to create template timeline in patch + // timeline template !exists + // Throw error to create timeline template in patch return { body: UPDATE_TEMPLATE_TIMELINE_ERROR_MESSAGE, statusCode: 405, @@ -98,7 +99,7 @@ const commonUpdateTemplateTimelineCheck = ( existTemplateTimeline != null && existTimeline.savedObjectId !== existTemplateTimeline.savedObjectId ) { - // Throw error you can not have a no matching between your timeline and your template timeline during an update + // Throw error you can not have a no matching between your timeline and your timeline template during an update return { body: NO_MATCH_ID_ERROR_MESSAGE, statusCode: 409, @@ -195,7 +196,7 @@ const createTemplateTimelineCheck = ( existTemplateTimeline: TimelineSavedObject | null ) => { if (isHandlingTemplateTimeline && existTemplateTimeline != null) { - // Throw error to create template timeline in patch + // Throw error to create timeline template in patch return { body: CREATE_TEMPLATE_TIMELINE_ERROR_MESSAGE, statusCode: 405, @@ -268,7 +269,7 @@ export const checkIsUpdateViaImportFailureCases = ( existTemplateTimeline.templateTimelineVersion != null && existTemplateTimeline.templateTimelineVersion >= templateTimelineVersion ) { - // Throw error you can not update a template timeline version with an old version + // Throw error you can not update a timeline template version with an old version return { body: TEMPLATE_TIMELINE_VERSION_CONFLICT_MESSAGE, statusCode: 409, @@ -369,7 +370,7 @@ export const checkIsCreateViaImportFailureCases = ( } } else { if (existTemplateTimeline != null) { - // Throw error to create template timeline in patch + // Throw error to create timeline template in patch return { body: getImportExistingTimelineError(existTemplateTimeline.savedObjectId), statusCode: 405, diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts index ec90fc6d8e071..f4dbd2db3329c 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts @@ -7,11 +7,7 @@ import { getOr } from 'lodash/fp'; import { SavedObjectsFindOptions } from '../../../../../../src/core/server'; -import { - UNAUTHENTICATED_USER, - disableTemplate, - enableElasticFilter, -} from '../../../common/constants'; +import { UNAUTHENTICATED_USER, enableElasticFilter } from '../../../common/constants'; import { NoteSavedObject } from '../../../common/types/timeline/note'; import { PinnedEventSavedObject } from '../../../common/types/timeline/pinned_event'; import { @@ -158,10 +154,9 @@ const getTimelineTypeFilter = ( ? `siem-ui-timeline.attributes.createdBy: "Elsatic"` : `not siem-ui-timeline.attributes.createdBy: "Elastic"`; - const filters = - !disableTemplate && enableElasticFilter - ? [typeFilter, draftFilter, immutableFilter, templateTimelineTypeFilter] - : [typeFilter, draftFilter, immutableFilter]; + const filters = enableElasticFilter + ? [typeFilter, draftFilter, immutableFilter, templateTimelineTypeFilter] + : [typeFilter, draftFilter, immutableFilter]; return filters.filter((f) => f != null).join(' and '); }; @@ -183,16 +178,7 @@ export const getAllTimeline = async ( searchFields: onlyUserFavorite ? ['title', 'description', 'favorite.keySearch'] : ['title', 'description'], - /** - * CreateTemplateTimelineBtn - * Remove the comment here to enable template timeline and apply the change below - * filter: getTimelineTypeFilter(timelineType, templateTimelineType, false) - */ - filter: getTimelineTypeFilter( - disableTemplate ? TimelineType.default : timelineType, - disableTemplate ? null : templateTimelineType, - disableTemplate ? null : status - ), + filter: getTimelineTypeFilter(timelineType, templateTimelineType, status), sortField: sort != null ? sort.sortField : undefined, sortOrder: sort != null ? sort.sortOrder : undefined, }; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings.ts index 51bff033b8791..22b98930f3181 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings.ts @@ -64,6 +64,9 @@ export const timelineSavedObjectMappings: SavedObjectsType['mappings'] = { kqlQuery: { type: 'text', }, + type: { + type: 'text', + }, queryMatch: { properties: { field: { @@ -100,6 +103,9 @@ export const timelineSavedObjectMappings: SavedObjectsType['mappings'] = { kqlQuery: { type: 'text', }, + type: { + type: 'text', + }, queryMatch: { properties: { field: {