diff --git a/examples/embeddable_examples/common/book_saved_object_attributes.ts b/examples/embeddable_examples/common/book_saved_object_attributes.ts new file mode 100644 index 0000000000000..62c08b7b81362 --- /dev/null +++ b/examples/embeddable_examples/common/book_saved_object_attributes.ts @@ -0,0 +1,28 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectAttributes } from '../../../src/core/types'; + +export const BOOK_SAVED_OBJECT = 'book'; + +export interface BookSavedObjectAttributes extends SavedObjectAttributes { + title: string; + author?: string; + readIt?: boolean; +} diff --git a/examples/embeddable_examples/common/index.ts b/examples/embeddable_examples/common/index.ts index 726420fb9bdc3..55715113a12a2 100644 --- a/examples/embeddable_examples/common/index.ts +++ b/examples/embeddable_examples/common/index.ts @@ -18,3 +18,4 @@ */ export { TodoSavedObjectAttributes } from './todo_saved_object_attributes'; +export { BookSavedObjectAttributes, BOOK_SAVED_OBJECT } from './book_saved_object_attributes'; diff --git a/examples/embeddable_examples/kibana.json b/examples/embeddable_examples/kibana.json index 486c6322fad93..8ae04c1f6c644 100644 --- a/examples/embeddable_examples/kibana.json +++ b/examples/embeddable_examples/kibana.json @@ -4,7 +4,7 @@ "kibanaVersion": "kibana", "server": true, "ui": true, - "requiredPlugins": ["embeddable"], + "requiredPlugins": ["embeddable", "uiActions"], "optionalPlugins": [], "extraPublicDirs": ["public/todo", "public/hello_world", "public/todo/todo_ref_embeddable"] } diff --git a/examples/embeddable_examples/public/book/book_component.tsx b/examples/embeddable_examples/public/book/book_component.tsx new file mode 100644 index 0000000000000..064e13c131a0a --- /dev/null +++ b/examples/embeddable_examples/public/book/book_component.tsx @@ -0,0 +1,90 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { EuiFlexItem, EuiFlexGroup, EuiIcon } from '@elastic/eui'; + +import { EuiText } from '@elastic/eui'; +import { EuiFlexGrid } from '@elastic/eui'; +import { withEmbeddableSubscription } from '../../../../src/plugins/embeddable/public'; +import { BookEmbeddableInput, BookEmbeddableOutput, BookEmbeddable } from './book_embeddable'; + +interface Props { + input: BookEmbeddableInput; + output: BookEmbeddableOutput; + embeddable: BookEmbeddable; +} + +function wrapSearchTerms(task?: string, search?: string) { + if (!search || !task) return task; + const parts = task.split(new RegExp(`(${search})`, 'g')); + return parts.map((part, i) => + part === search ? ( + + {part} + + ) : ( + part + ) + ); +} + +export function BookEmbeddableComponentInner({ input: { search }, output: { attributes } }: Props) { + const title = attributes?.title; + const author = attributes?.author; + const readIt = attributes?.readIt; + + return ( + + + + {title ? ( + + +

{wrapSearchTerms(title, search)},

+
+
+ ) : null} + {author ? ( + + +
-{wrapSearchTerms(author, search)}
+
+
+ ) : null} + {readIt ? ( + + + + ) : ( + + + + )} +
+
+
+ ); +} + +export const BookEmbeddableComponent = withEmbeddableSubscription< + BookEmbeddableInput, + BookEmbeddableOutput, + BookEmbeddable, + {} +>(BookEmbeddableComponentInner); diff --git a/examples/embeddable_examples/public/book/book_embeddable.tsx b/examples/embeddable_examples/public/book/book_embeddable.tsx new file mode 100644 index 0000000000000..d49bd3280d97d --- /dev/null +++ b/examples/embeddable_examples/public/book/book_embeddable.tsx @@ -0,0 +1,123 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Subscription } from 'rxjs'; +import { + Embeddable, + EmbeddableInput, + IContainer, + EmbeddableOutput, + SavedObjectEmbeddableInput, + AttributeService, +} from '../../../../src/plugins/embeddable/public'; +import { BookSavedObjectAttributes } from '../../common'; +import { BookEmbeddableComponent } from './book_component'; + +export const BOOK_EMBEDDABLE = 'book'; +export type BookEmbeddableInput = BookByValueInput | BookByReferenceInput; +export interface BookEmbeddableOutput extends EmbeddableOutput { + hasMatch: boolean; + attributes: BookSavedObjectAttributes; +} + +interface BookInheritedInput extends EmbeddableInput { + search?: string; +} + +export type BookByValueInput = { attributes: BookSavedObjectAttributes } & BookInheritedInput; +export type BookByReferenceInput = SavedObjectEmbeddableInput & BookInheritedInput; + +/** + * Returns whether any attributes contain the search string. If search is empty, true is returned. If + * there are no savedAttributes, false is returned. + * @param search - the search string + * @param savedAttributes - the saved object attributes for the saved object with id `input.savedObjectId` + */ +function getHasMatch(search?: string, savedAttributes?: BookSavedObjectAttributes): boolean { + if (!search) return true; + if (!savedAttributes) return false; + return Boolean( + (savedAttributes.author && savedAttributes.author.match(search)) || + (savedAttributes.title && savedAttributes.title.match(search)) + ); +} + +export class BookEmbeddable extends Embeddable { + public readonly type = BOOK_EMBEDDABLE; + private subscription: Subscription; + private node?: HTMLElement; + private savedObjectId?: string; + private attributes?: BookSavedObjectAttributes; + + constructor( + initialInput: BookEmbeddableInput, + private attributeService: AttributeService< + BookSavedObjectAttributes, + BookByValueInput, + BookByReferenceInput + >, + { + parent, + }: { + parent?: IContainer; + } + ) { + super(initialInput, {} as BookEmbeddableOutput, parent); + + this.subscription = this.getInput$().subscribe(async () => { + const savedObjectId = (this.getInput() as BookByReferenceInput).savedObjectId; + const attributes = (this.getInput() as BookByValueInput).attributes; + if (this.attributes !== attributes || this.savedObjectId !== savedObjectId) { + this.savedObjectId = savedObjectId; + this.reload(); + } else { + this.updateOutput({ + attributes: this.attributes, + hasMatch: getHasMatch(this.input.search, this.attributes), + }); + } + }); + } + + public render(node: HTMLElement) { + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + this.node = node; + ReactDOM.render(, node); + } + + public async reload() { + this.attributes = await this.attributeService.unwrapAttributes(this.input); + + this.updateOutput({ + attributes: this.attributes, + hasMatch: getHasMatch(this.input.search, this.attributes), + }); + } + + public destroy() { + super.destroy(); + this.subscription.unsubscribe(); + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + } +} diff --git a/examples/embeddable_examples/public/book/book_embeddable_factory.tsx b/examples/embeddable_examples/public/book/book_embeddable_factory.tsx new file mode 100644 index 0000000000000..f4a32fb498a2d --- /dev/null +++ b/examples/embeddable_examples/public/book/book_embeddable_factory.tsx @@ -0,0 +1,127 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { BookSavedObjectAttributes, BOOK_SAVED_OBJECT } from '../../common'; +import { toMountPoint } from '../../../../src/plugins/kibana_react/public'; +import { + EmbeddableFactoryDefinition, + EmbeddableStart, + IContainer, + AttributeService, + EmbeddableFactory, +} from '../../../../src/plugins/embeddable/public'; +import { + BookEmbeddable, + BOOK_EMBEDDABLE, + BookEmbeddableInput, + BookEmbeddableOutput, + BookByValueInput, + BookByReferenceInput, +} from './book_embeddable'; +import { CreateEditBookComponent } from './create_edit_book_component'; +import { OverlayStart } from '../../../../src/core/public'; + +interface StartServices { + getAttributeService: EmbeddableStart['getAttributeService']; + openModal: OverlayStart['openModal']; +} + +export type BookEmbeddableFactory = EmbeddableFactory< + BookEmbeddableInput, + BookEmbeddableOutput, + BookEmbeddable, + BookSavedObjectAttributes +>; + +export class BookEmbeddableFactoryDefinition + implements + EmbeddableFactoryDefinition< + BookEmbeddableInput, + BookEmbeddableOutput, + BookEmbeddable, + BookSavedObjectAttributes + > { + public readonly type = BOOK_EMBEDDABLE; + public savedObjectMetaData = { + name: 'Book', + includeFields: ['title', 'author', 'readIt'], + type: BOOK_SAVED_OBJECT, + getIconForSavedObject: () => 'pencil', + }; + + private attributeService?: AttributeService< + BookSavedObjectAttributes, + BookByValueInput, + BookByReferenceInput + >; + + constructor(private getStartServices: () => Promise) {} + + public async isEditable() { + return true; + } + + public async create(input: BookEmbeddableInput, parent?: IContainer) { + return new BookEmbeddable(input, await this.getAttributeService(), { + parent, + }); + } + + public getDisplayName() { + return i18n.translate('embeddableExamples.book.displayName', { + defaultMessage: 'Book', + }); + } + + public async getExplicitInput(): Promise> { + const { openModal } = await this.getStartServices(); + return new Promise>((resolve) => { + const onSave = async (attributes: BookSavedObjectAttributes, useRefType: boolean) => { + const wrappedAttributes = (await this.getAttributeService()).wrapAttributes( + attributes, + useRefType + ); + resolve(wrappedAttributes); + }; + const overlay = openModal( + toMountPoint( + { + onSave(attributes, useRefType); + overlay.close(); + }} + /> + ) + ); + }); + } + + private async getAttributeService() { + if (!this.attributeService) { + this.attributeService = await (await this.getStartServices()).getAttributeService< + BookSavedObjectAttributes, + BookByValueInput, + BookByReferenceInput + >(this.type); + } + return this.attributeService; + } +} diff --git a/examples/embeddable_examples/public/book/create_edit_book_component.tsx b/examples/embeddable_examples/public/book/create_edit_book_component.tsx new file mode 100644 index 0000000000000..7e2d3cb9d88ab --- /dev/null +++ b/examples/embeddable_examples/public/book/create_edit_book_component.tsx @@ -0,0 +1,88 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { useState } from 'react'; +import { EuiModalBody, EuiCheckbox } from '@elastic/eui'; +import { EuiFieldText } from '@elastic/eui'; +import { EuiButton } from '@elastic/eui'; +import { EuiModalFooter } from '@elastic/eui'; +import { EuiModalHeader } from '@elastic/eui'; +import { EuiFormRow } from '@elastic/eui'; +import { BookSavedObjectAttributes } from '../../common'; + +export function CreateEditBookComponent({ + savedObjectId, + attributes, + onSave, +}: { + savedObjectId?: string; + attributes?: BookSavedObjectAttributes; + onSave: (attributes: BookSavedObjectAttributes, useRefType: boolean) => void; +}) { + const [title, setTitle] = useState(attributes?.title ?? ''); + const [author, setAuthor] = useState(attributes?.author ?? ''); + const [readIt, setReadIt] = useState(attributes?.readIt ?? false); + return ( + + +

{`${savedObjectId ? 'Create new ' : 'Edit '}`}

+
+ + + setTitle(e.target.value)} + /> + + + setAuthor(e.target.value)} + /> + + + setReadIt(event.target.checked)} + /> + + + + onSave({ title, author, readIt }, false)} + > + {savedObjectId ? 'Unlink from library item' : 'Save and Return'} + + onSave({ title, author, readIt }, true)} + > + {savedObjectId ? 'Update library item' : 'Save to library'} + + +
+ ); +} diff --git a/examples/embeddable_examples/public/book/edit_book_action.tsx b/examples/embeddable_examples/public/book/edit_book_action.tsx new file mode 100644 index 0000000000000..222f70e0be60f --- /dev/null +++ b/examples/embeddable_examples/public/book/edit_book_action.tsx @@ -0,0 +1,93 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { OverlayStart } from 'kibana/public'; +import { i18n } from '@kbn/i18n'; +import { BookSavedObjectAttributes, BOOK_SAVED_OBJECT } from '../../common'; +import { createAction } from '../../../../src/plugins/ui_actions/public'; +import { toMountPoint } from '../../../../src/plugins/kibana_react/public'; +import { + ViewMode, + EmbeddableStart, + SavedObjectEmbeddableInput, +} from '../../../../src/plugins/embeddable/public'; +import { + BookEmbeddable, + BOOK_EMBEDDABLE, + BookByReferenceInput, + BookByValueInput, +} from './book_embeddable'; +import { CreateEditBookComponent } from './create_edit_book_component'; + +interface StartServices { + openModal: OverlayStart['openModal']; + getAttributeService: EmbeddableStart['getAttributeService']; +} + +interface ActionContext { + embeddable: BookEmbeddable; +} + +export const ACTION_EDIT_BOOK = 'ACTION_EDIT_BOOK'; + +export const createEditBookAction = (getStartServices: () => Promise) => + createAction({ + getDisplayName: () => + i18n.translate('embeddableExamples.book.edit', { defaultMessage: 'Edit Book' }), + type: ACTION_EDIT_BOOK, + order: 100, + getIconType: () => 'documents', + isCompatible: async ({ embeddable }: ActionContext) => { + return ( + embeddable.type === BOOK_EMBEDDABLE && embeddable.getInput().viewMode === ViewMode.EDIT + ); + }, + execute: async ({ embeddable }: ActionContext) => { + const { openModal, getAttributeService } = await getStartServices(); + const attributeService = getAttributeService< + BookSavedObjectAttributes, + BookByValueInput, + BookByReferenceInput + >(BOOK_SAVED_OBJECT); + const onSave = async (attributes: BookSavedObjectAttributes, useRefType: boolean) => { + const newInput = await attributeService.wrapAttributes(attributes, useRefType, embeddable); + if (!useRefType && (embeddable.getInput() as SavedObjectEmbeddableInput).savedObjectId) { + // Remove the savedObejctId when un-linking + newInput.savedObjectId = null; + } + embeddable.updateInput(newInput); + if (useRefType) { + // Ensures that any duplicate embeddables also register the changes. This mirrors the behavior of going back and forth between apps + embeddable.getRoot().reload(); + } + }; + const overlay = openModal( + toMountPoint( + { + overlay.close(); + onSave(attributes, useRefType); + }} + /> + ) + ); + }, + }); diff --git a/examples/embeddable_examples/public/book/index.ts b/examples/embeddable_examples/public/book/index.ts new file mode 100644 index 0000000000000..46f44926e2152 --- /dev/null +++ b/examples/embeddable_examples/public/book/index.ts @@ -0,0 +1,21 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './book_embeddable'; +export * from './book_embeddable_factory'; diff --git a/examples/embeddable_examples/public/create_sample_data.ts b/examples/embeddable_examples/public/create_sample_data.ts index bd5ade18aa91e..d598c32a182fe 100644 --- a/examples/embeddable_examples/public/create_sample_data.ts +++ b/examples/embeddable_examples/public/create_sample_data.ts @@ -18,9 +18,9 @@ */ import { SavedObjectsClientContract } from 'kibana/public'; -import { TodoSavedObjectAttributes } from '../common'; +import { TodoSavedObjectAttributes, BookSavedObjectAttributes, BOOK_SAVED_OBJECT } from '../common'; -export async function createSampleData(client: SavedObjectsClientContract) { +export async function createSampleData(client: SavedObjectsClientContract, overwrite = true) { await client.create( 'todo', { @@ -30,7 +30,20 @@ export async function createSampleData(client: SavedObjectsClientContract) { }, { id: 'sample-todo-saved-object', - overwrite: true, + overwrite, + } + ); + + await client.create( + BOOK_SAVED_OBJECT, + { + title: 'Pillars of the Earth', + author: 'Ken Follett', + readIt: true, + }, + { + id: 'sample-book-saved-object', + overwrite, } ); } diff --git a/examples/embeddable_examples/public/index.ts b/examples/embeddable_examples/public/index.ts index ec007f7c626f0..86f50f2b6e114 100644 --- a/examples/embeddable_examples/public/index.ts +++ b/examples/embeddable_examples/public/index.ts @@ -26,6 +26,8 @@ export { export { ListContainer, LIST_CONTAINER, ListContainerFactory } from './list_container'; export { TODO_EMBEDDABLE, TodoEmbeddableFactory } from './todo'; +export { BOOK_EMBEDDABLE } from './book'; + import { EmbeddableExamplesPlugin } from './plugin'; export { diff --git a/examples/embeddable_examples/public/plugin.ts b/examples/embeddable_examples/public/plugin.ts index d65ca1e8e7e8d..95f4f5b41e198 100644 --- a/examples/embeddable_examples/public/plugin.ts +++ b/examples/embeddable_examples/public/plugin.ts @@ -17,14 +17,19 @@ * under the License. */ -import { EmbeddableSetup, EmbeddableStart } from '../../../src/plugins/embeddable/public'; -import { CoreSetup, CoreStart, Plugin } from '../../../src/core/public'; import { + EmbeddableSetup, + EmbeddableStart, + CONTEXT_MENU_TRIGGER, +} from '../../../src/plugins/embeddable/public'; +import { Plugin, CoreSetup, CoreStart } from '../../../src/core/public'; +import { + HelloWorldEmbeddableFactory, HELLO_WORLD_EMBEDDABLE, HelloWorldEmbeddableFactoryDefinition, - HelloWorldEmbeddableFactory, } from './hello_world'; import { TODO_EMBEDDABLE, TodoEmbeddableFactory, TodoEmbeddableFactoryDefinition } from './todo'; + import { MULTI_TASK_TODO_EMBEDDABLE, MultiTaskTodoEmbeddableFactory, @@ -46,9 +51,17 @@ import { TodoRefEmbeddableFactory, TodoRefEmbeddableFactoryDefinition, } from './todo/todo_ref_embeddable_factory'; +import { ACTION_EDIT_BOOK, createEditBookAction } from './book/edit_book_action'; +import { BookEmbeddable, BOOK_EMBEDDABLE } from './book/book_embeddable'; +import { + BookEmbeddableFactory, + BookEmbeddableFactoryDefinition, +} from './book/book_embeddable_factory'; +import { UiActionsStart } from '../../../src/plugins/ui_actions/public'; export interface EmbeddableExamplesSetupDependencies { embeddable: EmbeddableSetup; + uiActions: UiActionsStart; } export interface EmbeddableExamplesStartDependencies { @@ -62,6 +75,7 @@ interface ExampleEmbeddableFactories { getListContainerEmbeddableFactory: () => ListContainerFactory; getTodoEmbeddableFactory: () => TodoEmbeddableFactory; getTodoRefEmbeddableFactory: () => TodoRefEmbeddableFactory; + getBookEmbeddableFactory: () => BookEmbeddableFactory; } export interface EmbeddableExamplesStart { @@ -69,6 +83,12 @@ export interface EmbeddableExamplesStart { factories: ExampleEmbeddableFactories; } +declare module '../../../src/plugins/ui_actions/public' { + export interface ActionContextMapping { + [ACTION_EDIT_BOOK]: { embeddable: BookEmbeddable }; + } +} + export class EmbeddableExamplesPlugin implements Plugin< @@ -121,6 +141,20 @@ export class EmbeddableExamplesPlugin getEmbeddableFactory: (await core.getStartServices())[1].embeddable.getEmbeddableFactory, })) ); + this.exampleEmbeddableFactories.getBookEmbeddableFactory = deps.embeddable.registerEmbeddableFactory( + BOOK_EMBEDDABLE, + new BookEmbeddableFactoryDefinition(async () => ({ + getAttributeService: (await core.getStartServices())[1].embeddable.getAttributeService, + openModal: (await core.getStartServices())[0].overlays.openModal, + })) + ); + + const editBookAction = createEditBookAction(async () => ({ + getAttributeService: (await core.getStartServices())[1].embeddable.getAttributeService, + openModal: (await core.getStartServices())[0].overlays.openModal, + })); + deps.uiActions.registerAction(editBookAction); + deps.uiActions.attachAction(CONTEXT_MENU_TRIGGER, editBookAction.id); } public start( diff --git a/examples/embeddable_examples/server/book_saved_object.ts b/examples/embeddable_examples/server/book_saved_object.ts new file mode 100644 index 0000000000000..f0aca57f7925f --- /dev/null +++ b/examples/embeddable_examples/server/book_saved_object.ts @@ -0,0 +1,40 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectsType } from 'kibana/server'; + +export const bookSavedObject: SavedObjectsType = { + name: 'book', + hidden: false, + namespaceType: 'agnostic', + mappings: { + properties: { + title: { + type: 'keyword', + }, + author: { + type: 'keyword', + }, + readIt: { + type: 'boolean', + }, + }, + }, + migrations: {}, +}; diff --git a/examples/embeddable_examples/server/plugin.ts b/examples/embeddable_examples/server/plugin.ts index d956b834d0d3c..1308ac9e0fc5e 100644 --- a/examples/embeddable_examples/server/plugin.ts +++ b/examples/embeddable_examples/server/plugin.ts @@ -19,10 +19,12 @@ import { Plugin, CoreSetup, CoreStart } from 'kibana/server'; import { todoSavedObject } from './todo_saved_object'; +import { bookSavedObject } from './book_saved_object'; export class EmbeddableExamplesPlugin implements Plugin { public setup(core: CoreSetup) { core.savedObjects.registerType(todoSavedObject); + core.savedObjects.registerType(bookSavedObject); } public start(core: CoreStart) {} diff --git a/examples/embeddable_explorer/public/embeddable_panel_example.tsx b/examples/embeddable_explorer/public/embeddable_panel_example.tsx index b2807f9a4c346..ca9675bb7f5a1 100644 --- a/examples/embeddable_explorer/public/embeddable_panel_example.tsx +++ b/examples/embeddable_explorer/public/embeddable_panel_example.tsx @@ -33,6 +33,7 @@ import { EmbeddableStart, IEmbeddable } from '../../../src/plugins/embeddable/pu import { HELLO_WORLD_EMBEDDABLE, TODO_EMBEDDABLE, + BOOK_EMBEDDABLE, MULTI_TASK_TODO_EMBEDDABLE, SearchableListContainerFactory, } from '../../embeddable_examples/public'; @@ -72,6 +73,35 @@ export function EmbeddablePanelExample({ embeddableServices, searchListContainer tasks: ['Go to school', 'Watch planet earth', 'Read the encyclopedia'], }, }, + '4': { + type: BOOK_EMBEDDABLE, + explicitInput: { + id: '4', + savedObjectId: 'sample-book-saved-object', + }, + }, + '5': { + type: BOOK_EMBEDDABLE, + explicitInput: { + id: '5', + attributes: { + title: 'The Sympathizer', + author: 'Viet Thanh Nguyen', + readIt: true, + }, + }, + }, + '6': { + type: BOOK_EMBEDDABLE, + explicitInput: { + id: '6', + attributes: { + title: 'The Hobbit', + author: 'J.R.R. Tolkien', + readIt: false, + }, + }, + }, }, }; diff --git a/src/plugins/console/public/application/components/editor_example.tsx b/src/plugins/console/public/application/components/editor_example.tsx index e9e252e4ebb17..72a1056b1a866 100644 --- a/src/plugins/console/public/application/components/editor_example.tsx +++ b/src/plugins/console/public/application/components/editor_example.tsx @@ -18,8 +18,6 @@ */ import { EuiScreenReaderOnly } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -// @ts-ignore -import exampleText from 'raw-loader!../constants/help_example.txt'; import React, { useEffect } from 'react'; import { createReadOnlyAceEditor } from '../models/legacy_core_editor'; @@ -27,6 +25,17 @@ interface EditorExampleProps { panel: string; } +const exampleText = ` +# index a doc +PUT index/1 +{ + "body": "here" +} + +# and get it ... +GET index/1 +`; + export function EditorExample(props: EditorExampleProps) { const elemId = `help-example-${props.panel}`; const inputId = `help-example-${props.panel}-input`; diff --git a/src/plugins/console/public/application/constants/help_example.txt b/src/plugins/console/public/application/constants/help_example.txt deleted file mode 100644 index fd37c41367033..0000000000000 --- a/src/plugins/console/public/application/constants/help_example.txt +++ /dev/null @@ -1,8 +0,0 @@ -# index a doc -PUT index/1 -{ - "body": "here" -} - -# and get it ... -GET index/1 diff --git a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx index a321bc7959c5c..8138e1c7f4dfd 100644 --- a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx @@ -60,6 +60,7 @@ import { ViewMode, ContainerOutput, EmbeddableInput, + SavedObjectEmbeddableInput, } from '../../../embeddable/public'; import { NavAction, SavedDashboardPanel } from '../types'; @@ -431,7 +432,7 @@ export class DashboardAppController { .getIncomingEmbeddablePackage(); if (incomingState) { if ('id' in incomingState) { - container.addNewEmbeddable(incomingState.type, { + container.addOrUpdateEmbeddable(incomingState.type, { savedObjectId: incomingState.id, }); } else if ('input' in incomingState) { @@ -440,7 +441,7 @@ export class DashboardAppController { const explicitInput = { savedVis: input, }; - container.addNewEmbeddable(incomingState.type, explicitInput); + container.addOrUpdateEmbeddable(incomingState.type, explicitInput); } } } diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx index f1ecd0f221926..ff74580ba256b 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx @@ -46,7 +46,7 @@ import { } from '../../../../kibana_react/public'; import { PLACEHOLDER_EMBEDDABLE } from './placeholder'; import { PanelPlacementMethod, IPanelPlacementArgs } from './panel/dashboard_panel_placement'; -import { EmbeddableStateTransfer } from '../../../../embeddable/public'; +import { EmbeddableStateTransfer, EmbeddableOutput } from '../../../../embeddable/public'; export interface DashboardContainerInput extends ContainerInput { viewMode: ViewMode; @@ -159,29 +159,55 @@ export class DashboardContainer extends Container) => { - const finalPanels = { ...this.input.panels }; - delete finalPanels[placeholderPanelState.explicitInput.id]; - const newPanelId = newPanelState.explicitInput?.id - ? newPanelState.explicitInput.id - : uuid.v4(); - finalPanels[newPanelId] = { - ...placeholderPanelState, - ...newPanelState, - gridData: { - ...placeholderPanelState.gridData, - i: newPanelId, - }, + newStateComplete.then((newPanelState: Partial) => + this.replacePanel(placeholderPanelState, newPanelState) + ); + } + + public replacePanel( + previousPanelState: DashboardPanelState, + newPanelState: Partial + ) { + // TODO: In the current infrastructure, embeddables in a container do not react properly to + // changes. Removing the existing embeddable, and adding a new one is a temporary workaround + // until the container logic is fixed. + const finalPanels = { ...this.input.panels }; + delete finalPanels[previousPanelState.explicitInput.id]; + const newPanelId = newPanelState.explicitInput?.id ? newPanelState.explicitInput.id : uuid.v4(); + finalPanels[newPanelId] = { + ...previousPanelState, + ...newPanelState, + gridData: { + ...previousPanelState.gridData, + i: newPanelId, + }, + explicitInput: { + ...newPanelState.explicitInput, + id: newPanelId, + }, + }; + this.updateInput({ + panels: finalPanels, + lastReloadRequestTime: new Date().getTime(), + }); + } + + public async addOrUpdateEmbeddable< + EEI extends EmbeddableInput = EmbeddableInput, + EEO extends EmbeddableOutput = EmbeddableOutput, + E extends IEmbeddable = IEmbeddable + >(type: string, explicitInput: Partial) { + if (explicitInput.id && this.input.panels[explicitInput.id]) { + this.replacePanel(this.input.panels[explicitInput.id], { + type, explicitInput: { - ...newPanelState.explicitInput, - id: newPanelId, + ...explicitInput, + id: uuid.v4(), }, - }; - this.updateInput({ - panels: finalPanels, - lastReloadRequestTime: new Date().getTime(), }); - }); + } else { + this.addNewEmbeddable(type, explicitInput); + } } public render(dom: HTMLElement) { diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 6960550b59d1c..fafbdda148de8 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -28,6 +28,7 @@ export { ACTION_EDIT_PANEL, Adapters, AddPanelAction, + AttributeService, ChartActionContext, Container, ContainerInput, diff --git a/src/plugins/embeddable/public/lib/embeddables/attribute_service.ts b/src/plugins/embeddable/public/lib/embeddables/attribute_service.ts new file mode 100644 index 0000000000000..a33f592350d9a --- /dev/null +++ b/src/plugins/embeddable/public/lib/embeddables/attribute_service.ts @@ -0,0 +1,68 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectsClientContract } from '../../../../../core/public'; +import { + SavedObjectEmbeddableInput, + isSavedObjectEmbeddableInput, + EmbeddableInput, + IEmbeddable, +} from '.'; +import { SimpleSavedObject } from '../../../../../core/public'; + +export class AttributeService< + SavedObjectAttributes, + ValType extends EmbeddableInput & { attributes: SavedObjectAttributes }, + RefType extends SavedObjectEmbeddableInput +> { + constructor(private type: string, private savedObjectsClient: SavedObjectsClientContract) {} + + public async unwrapAttributes(input: RefType | ValType): Promise { + if (isSavedObjectEmbeddableInput(input)) { + const savedObject: SimpleSavedObject = await this.savedObjectsClient.get< + SavedObjectAttributes + >(this.type, input.savedObjectId); + return savedObject.attributes; + } + return input.attributes; + } + + public async wrapAttributes( + newAttributes: SavedObjectAttributes, + useRefType: boolean, + embeddable?: IEmbeddable + ): Promise> { + const savedObjectId = + embeddable && isSavedObjectEmbeddableInput(embeddable.getInput()) + ? (embeddable.getInput() as SavedObjectEmbeddableInput).savedObjectId + : undefined; + + if (useRefType) { + if (savedObjectId) { + await this.savedObjectsClient.update(this.type, savedObjectId, newAttributes); + return { savedObjectId } as RefType; + } else { + const savedItem = await this.savedObjectsClient.create(this.type, newAttributes); + return { savedObjectId: savedItem.id } as RefType; + } + } else { + return { attributes: newAttributes } as ValType; + } + } +} diff --git a/src/plugins/embeddable/public/lib/embeddables/index.ts b/src/plugins/embeddable/public/lib/embeddables/index.ts index 5bab5ac27f3cc..06cb6e322acf3 100644 --- a/src/plugins/embeddable/public/lib/embeddables/index.ts +++ b/src/plugins/embeddable/public/lib/embeddables/index.ts @@ -25,4 +25,5 @@ export { ErrorEmbeddable, isErrorEmbeddable } from './error_embeddable'; export { withEmbeddableSubscription } from './with_subscription'; export { EmbeddableRoot } from './embeddable_root'; export * from './saved_object_embeddable'; +export { AttributeService } from './attribute_service'; export { EmbeddableRenderer, EmbeddableRendererProps } from './embeddable_renderer'; diff --git a/src/plugins/embeddable/public/lib/embeddables/saved_object_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/saved_object_embeddable.ts index 6ca1800b16de4..5f093c55e94e4 100644 --- a/src/plugins/embeddable/public/lib/embeddables/saved_object_embeddable.ts +++ b/src/plugins/embeddable/public/lib/embeddables/saved_object_embeddable.ts @@ -26,5 +26,5 @@ export interface SavedObjectEmbeddableInput extends EmbeddableInput { export function isSavedObjectEmbeddableInput( input: EmbeddableInput | SavedObjectEmbeddableInput ): input is SavedObjectEmbeddableInput { - return (input as SavedObjectEmbeddableInput).savedObjectId !== undefined; + return Boolean((input as SavedObjectEmbeddableInput).savedObjectId); } diff --git a/src/plugins/embeddable/public/mocks.tsx b/src/plugins/embeddable/public/mocks.tsx index efd0ccdc4553d..48e5483124704 100644 --- a/src/plugins/embeddable/public/mocks.tsx +++ b/src/plugins/embeddable/public/mocks.tsx @@ -99,6 +99,7 @@ const createStartContract = (): Start => { getEmbeddableFactories: jest.fn(), getEmbeddableFactory: jest.fn(), EmbeddablePanel: jest.fn(), + getAttributeService: jest.fn(), getEmbeddablePanel: jest.fn(), getStateTransfer: jest.fn(() => createEmbeddableStateTransferMock() as EmbeddableStateTransfer), filtersAndTimeRangeFromContext: jest.fn(), diff --git a/src/plugins/embeddable/public/plugin.tsx b/src/plugins/embeddable/public/plugin.tsx index 03bb4a4779267..508c82c4247ed 100644 --- a/src/plugins/embeddable/public/plugin.tsx +++ b/src/plugins/embeddable/public/plugin.tsx @@ -43,11 +43,13 @@ import { defaultEmbeddableFactoryProvider, IEmbeddable, EmbeddablePanel, + SavedObjectEmbeddableInput, ChartActionContext, isRangeSelectTriggerContext, isValueClickTriggerContext, } from './lib'; import { EmbeddableFactoryDefinition } from './lib/embeddables/embeddable_factory_definition'; +import { AttributeService } from './lib/embeddables/attribute_service'; import { EmbeddableStateTransfer } from './lib/state_transfer'; export interface EmbeddableSetupDependencies { @@ -82,6 +84,13 @@ export interface EmbeddableStart { embeddableFactoryId: string ) => EmbeddableFactory | undefined; getEmbeddableFactories: () => IterableIterator; + getAttributeService: < + A, + V extends EmbeddableInput & { attributes: A }, + R extends SavedObjectEmbeddableInput + >( + type: string + ) => AttributeService; /** * Given {@link ChartActionContext} returns a list of `data` plugin {@link Filter} entries. @@ -206,6 +215,7 @@ export class EmbeddablePublicPlugin implements Plugin new AttributeService(type, core.savedObjects.client), filtersFromContext, filtersAndTimeRangeFromContext, getStateTransfer: (history?: ScopedHistory) => { diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts index 225432375dc75..e5037a6477aca 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts @@ -5,6 +5,8 @@ */ export const POLICY_NAME = 'my_policy'; +export const SNAPSHOT_POLICY_NAME = 'my_snapshot_policy'; +export const NEW_SNAPSHOT_POLICY_NAME = 'my_new_snapshot_policy'; export const DELETE_PHASE_POLICY = { version: 1, @@ -26,7 +28,7 @@ export const DELETE_PHASE_POLICY = { min_age: '0ms', actions: { wait_for_snapshot: { - policy: 'my_snapshot_policy', + policy: SNAPSHOT_POLICY_NAME, }, delete: { delete_searchable_snapshot: true, diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx index d6c955e0c0813..cba496ee0f212 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import React from 'react'; import { act } from 'react-dom/test-utils'; import { registerTestBed, TestBed, TestBedConfig } from '../../../../../test_utils'; @@ -14,6 +15,25 @@ import { TestSubjects } from '../helpers'; import { EditPolicy } from '../../../public/application/sections/edit_policy'; import { indexLifecycleManagementStore } from '../../../public/application/store'; +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + + return { + ...original, + // Mocking EuiComboBox, as it utilizes "react-virtualized" for rendering search suggestions, + // which does not produce a valid component wrapper + EuiComboBox: (props: any) => ( + { + props.onChange([syntheticEvent['0']]); + }} + /> + ), + }; +}); + const testBedConfig: TestBedConfig = { store: () => indexLifecycleManagementStore(), memoryRouter: { @@ -34,9 +54,11 @@ export interface EditPolicyTestBed extends TestBed { export const setup = async (): Promise => { const testBed = await initTestBed(); - const setWaitForSnapshotPolicy = (snapshotPolicyName: string) => { - const { component, form } = testBed; - form.setInputValue('waitForSnapshotField', snapshotPolicyName, true); + const setWaitForSnapshotPolicy = async (snapshotPolicyName: string) => { + const { component } = testBed; + act(() => { + testBed.find('snapshotPolicyCombobox').simulate('change', [{ label: snapshotPolicyName }]); + }); component.update(); }; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts index 8753f01376d42..06829e6ef6f1e 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts @@ -7,11 +7,10 @@ import { act } from 'react-dom/test-utils'; import { setupEnvironment } from '../helpers/setup_environment'; - import { EditPolicyTestBed, setup } from './edit_policy.helpers'; -import { DELETE_PHASE_POLICY } from './constants'; import { API_BASE_PATH } from '../../../common/constants'; +import { DELETE_PHASE_POLICY, NEW_SNAPSHOT_POLICY_NAME, SNAPSHOT_POLICY_NAME } from './constants'; window.scrollTo = jest.fn(); @@ -25,6 +24,10 @@ describe('', () => { describe('delete phase', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadPolicies([DELETE_PHASE_POLICY]); + httpRequestsMockHelpers.setLoadSnapshotPolicies([ + SNAPSHOT_POLICY_NAME, + NEW_SNAPSHOT_POLICY_NAME, + ]); await act(async () => { testBed = await setup(); @@ -35,16 +38,18 @@ describe('', () => { }); test('wait for snapshot policy field should correctly display snapshot policy name', () => { - expect(testBed.find('waitForSnapshotField').props().value).toEqual( - DELETE_PHASE_POLICY.policy.phases.delete.actions.wait_for_snapshot.policy - ); + expect(testBed.find('snapshotPolicyCombobox').prop('data-currentvalue')).toEqual([ + { + label: DELETE_PHASE_POLICY.policy.phases.delete.actions.wait_for_snapshot.policy, + value: DELETE_PHASE_POLICY.policy.phases.delete.actions.wait_for_snapshot.policy, + }, + ]); }); test('wait for snapshot field should correctly update snapshot policy name', async () => { const { actions } = testBed; - const newPolicyName = 'my_new_snapshot_policy'; - actions.setWaitForSnapshotPolicy(newPolicyName); + await actions.setWaitForSnapshotPolicy(NEW_SNAPSHOT_POLICY_NAME); await actions.savePolicy(); const expected = { @@ -56,7 +61,7 @@ describe('', () => { actions: { ...DELETE_PHASE_POLICY.policy.phases.delete.actions, wait_for_snapshot: { - policy: newPolicyName, + policy: NEW_SNAPSHOT_POLICY_NAME, }, }, }, @@ -69,6 +74,15 @@ describe('', () => { expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); }); + test('wait for snapshot field should display a callout when the input is not an existing policy', async () => { + const { actions } = testBed; + + await actions.setWaitForSnapshotPolicy('my_custom_policy'); + expect(testBed.find('noPoliciesCallout').exists()).toBeFalsy(); + expect(testBed.find('policiesErrorCallout').exists()).toBeFalsy(); + expect(testBed.find('customPolicyCallout').exists()).toBeTruthy(); + }); + test('wait for snapshot field should delete action if field is empty', async () => { const { actions } = testBed; @@ -92,5 +106,31 @@ describe('', () => { const latestRequest = server.requests[server.requests.length - 1]; expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); }); + + test('wait for snapshot field should display a callout when there are no snapshot policies', async () => { + // need to call setup on testBed again for it to use a newly defined snapshot policies response + httpRequestsMockHelpers.setLoadSnapshotPolicies([]); + await act(async () => { + testBed = await setup(); + }); + + testBed.component.update(); + expect(testBed.find('customPolicyCallout').exists()).toBeFalsy(); + expect(testBed.find('policiesErrorCallout').exists()).toBeFalsy(); + expect(testBed.find('noPoliciesCallout').exists()).toBeTruthy(); + }); + + test('wait for snapshot field should display a callout when there is an error loading snapshot policies', async () => { + // need to call setup on testBed again for it to use a newly defined snapshot policies response + httpRequestsMockHelpers.setLoadSnapshotPolicies([], { status: 500, body: 'error' }); + await act(async () => { + testBed = await setup(); + }); + + testBed.component.update(); + expect(testBed.find('customPolicyCallout').exists()).toBeFalsy(); + expect(testBed.find('noPoliciesCallout').exists()).toBeFalsy(); + expect(testBed.find('policiesErrorCallout').exists()).toBeTruthy(); + }); }); }); diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/http_requests.ts index f41742fc104ff..04f58f93939ca 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/http_requests.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SinonFakeServer, fakeServer } from 'sinon'; +import { fakeServer, SinonFakeServer } from 'sinon'; import { API_BASE_PATH } from '../../../common/constants'; export const init = () => { @@ -27,7 +27,19 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { ]); }; + const setLoadSnapshotPolicies = (response: any = [], error?: { status: number; body: any }) => { + const status = error ? error.status : 200; + const body = error ? error.body : response; + + server.respondWith('GET', `${API_BASE_PATH}/snapshot_policies`, [ + status, + { 'Content-Type': 'application/json' }, + JSON.stringify(body), + ]); + }; + return { setLoadPolicies, + setLoadSnapshotPolicies, }; }; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/index.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/index.ts index 3cff2e3ab050f..7b227f822fa97 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/index.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/helpers/index.ts @@ -4,4 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -export type TestSubjects = 'waitForSnapshotField' | 'savePolicyButton'; +export type TestSubjects = + | 'snapshotPolicyCombobox' + | 'savePolicyButton' + | 'customPolicyCallout' + | 'noPoliciesCallout' + | 'policiesErrorCallout'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/delete_phase/delete_phase.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/delete_phase/delete_phase.js index 299bf28778ab4..34d1c0f8de216 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/delete_phase/delete_phase.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/delete_phase/delete_phase.js @@ -7,17 +7,12 @@ import React, { PureComponent, Fragment } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiDescribedFormGroup, - EuiSwitch, - EuiFieldText, - EuiTextColor, - EuiFormRow, -} from '@elastic/eui'; +import { EuiDescribedFormGroup, EuiSwitch, EuiTextColor, EuiFormRow } from '@elastic/eui'; import { PHASE_DELETE, PHASE_ENABLED, PHASE_WAIT_FOR_SNAPSHOT_POLICY } from '../../../../constants'; import { ActiveBadge, LearnMoreLink, OptionalLabel, PhaseErrorMessage } from '../../../components'; import { MinAgeInput } from '../min_age_input'; +import { SnapshotPolicies } from '../snapshot_policies'; export class DeletePhase extends PureComponent { static propTypes = { @@ -125,10 +120,9 @@ export class DeletePhase extends PureComponent { } > - setPhaseData(PHASE_WAIT_FOR_SNAPSHOT_POLICY, e.target.value)} + onChange={(value) => setPhaseData(PHASE_WAIT_FOR_SNAPSHOT_POLICY, value)} /> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/snapshot_policies/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/snapshot_policies/index.ts new file mode 100644 index 0000000000000..f33ce81eb6157 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/snapshot_policies/index.ts @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { SnapshotPolicies } from './snapshot_policies'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/snapshot_policies/snapshot_policies.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/snapshot_policies/snapshot_policies.tsx new file mode 100644 index 0000000000000..76eae0f906d0c --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/snapshot_policies/snapshot_policies.tsx @@ -0,0 +1,157 @@ +/* + * 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, { Fragment } from 'react'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { + EuiButtonIcon, + EuiCallOut, + EuiComboBox, + EuiComboBoxOptionOption, + EuiSpacer, +} from '@elastic/eui'; + +import { useLoadSnapshotPolicies } from '../../../../services/api'; + +interface Props { + value: string; + onChange: (value: string) => void; +} +export const SnapshotPolicies: React.FunctionComponent = ({ value, onChange }) => { + const { error, isLoading, data, sendRequest } = useLoadSnapshotPolicies(); + + const policies = data.map((name: string) => ({ + label: name, + value: name, + })); + + const onComboChange = (options: EuiComboBoxOptionOption[]) => { + if (options.length > 0) { + onChange(options[0].label); + } else { + onChange(''); + } + }; + + const onCreateOption = (newValue: string) => { + onChange(newValue); + }; + + let calloutContent; + if (error) { + calloutContent = ( + + + + + + + + } + > + + + + ); + } else if (data.length === 0) { + calloutContent = ( + + + + } + > + + + + ); + } else if (value && !data.includes(value)) { + calloutContent = ( + + + + } + > + + + + ); + } + + return ( + + + {calloutContent} + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/api.js b/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts similarity index 56% rename from x-pack/plugins/index_lifecycle_management/public/application/services/api.js rename to x-pack/plugins/index_lifecycle_management/public/application/services/api.ts index 6b46d6e6ea735..065fb3bcebca7 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/api.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts @@ -4,6 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import { METRIC_TYPE } from '@kbn/analytics'; +import { trackUiMetric } from './ui_metric'; + import { UIM_POLICY_DELETE, UIM_POLICY_ATTACH_INDEX, @@ -12,14 +15,13 @@ import { UIM_INDEX_RETRY_STEP, } from '../constants'; -import { trackUiMetric } from './ui_metric'; -import { sendGet, sendPost, sendDelete } from './http'; +import { sendGet, sendPost, sendDelete, useRequest } from './http'; export async function loadNodes() { return await sendGet(`nodes/list`); } -export async function loadNodeDetails(selectedNodeAttrs) { +export async function loadNodeDetails(selectedNodeAttrs: string) { return await sendGet(`nodes/${selectedNodeAttrs}/details`); } @@ -27,45 +29,53 @@ export async function loadIndexTemplates() { return await sendGet(`templates`); } -export async function loadPolicies(withIndices) { +export async function loadPolicies(withIndices: boolean) { return await sendGet('policies', { withIndices }); } -export async function savePolicy(policy) { +export async function savePolicy(policy: any) { return await sendPost(`policies`, policy); } -export async function deletePolicy(policyName) { +export async function deletePolicy(policyName: string) { const response = await sendDelete(`policies/${encodeURIComponent(policyName)}`); // Only track successful actions. - trackUiMetric('count', UIM_POLICY_DELETE); + trackUiMetric(METRIC_TYPE.COUNT, UIM_POLICY_DELETE); return response; } -export const retryLifecycleForIndex = async (indexNames) => { +export const retryLifecycleForIndex = async (indexNames: string[]) => { const response = await sendPost(`index/retry`, { indexNames }); // Only track successful actions. - trackUiMetric('count', UIM_INDEX_RETRY_STEP); + trackUiMetric(METRIC_TYPE.COUNT, UIM_INDEX_RETRY_STEP); return response; }; -export const removeLifecycleForIndex = async (indexNames) => { +export const removeLifecycleForIndex = async (indexNames: string[]) => { const response = await sendPost(`index/remove`, { indexNames }); // Only track successful actions. - trackUiMetric('count', UIM_POLICY_DETACH_INDEX); + trackUiMetric(METRIC_TYPE.COUNT, UIM_POLICY_DETACH_INDEX); return response; }; -export const addLifecyclePolicyToIndex = async (body) => { +export const addLifecyclePolicyToIndex = async (body: any) => { const response = await sendPost(`index/add`, body); // Only track successful actions. - trackUiMetric('count', UIM_POLICY_ATTACH_INDEX); + trackUiMetric(METRIC_TYPE.COUNT, UIM_POLICY_ATTACH_INDEX); return response; }; -export const addLifecyclePolicyToTemplate = async (body) => { +export const addLifecyclePolicyToTemplate = async (body: any) => { const response = await sendPost(`template`, body); // Only track successful actions. - trackUiMetric('count', UIM_POLICY_ATTACH_INDEX_TEMPLATE); + trackUiMetric(METRIC_TYPE.COUNT, UIM_POLICY_ATTACH_INDEX_TEMPLATE); return response; }; + +export const useLoadSnapshotPolicies = () => { + return useRequest({ + path: `snapshot_policies`, + method: 'get', + initialData: [], + }); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/http.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/http.ts index 47e96ea28bb8c..c54ee15fd69bf 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/http.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/http.ts @@ -4,6 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { + UseRequestConfig, + useRequest as _useRequest, + Error, +} from '../../../../../../src/plugins/es_ui_shared/public'; + let _httpClient: any; export function init(httpClient: any): void { @@ -24,10 +30,14 @@ export function sendPost(path: string, payload: any): any { return _httpClient.post(getFullPath(path), { body: JSON.stringify(payload) }); } -export function sendGet(path: string, query: any): any { +export function sendGet(path: string, query?: any): any { return _httpClient.get(getFullPath(path), { query }); } export function sendDelete(path: string): any { return _httpClient.delete(getFullPath(path)); } + +export const useRequest = (config: UseRequestConfig) => { + return _useRequest(_httpClient, { ...config, path: getFullPath(config.path) }); +}; diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/snapshot_policies/index.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/snapshot_policies/index.ts new file mode 100644 index 0000000000000..19fbc45010ea2 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/snapshot_policies/index.ts @@ -0,0 +1,12 @@ +/* + * 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 { RouteDependencies } from '../../../types'; +import { registerFetchRoute } from './register_fetch_route'; + +export function registerSnapshotPoliciesRoutes(dependencies: RouteDependencies) { + registerFetchRoute(dependencies); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/snapshot_policies/register_fetch_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/snapshot_policies/register_fetch_route.ts new file mode 100644 index 0000000000000..7a52648e29ee8 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/snapshot_policies/register_fetch_route.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { LegacyAPICaller } from 'src/core/server'; + +import { RouteDependencies } from '../../../types'; +import { addBasePath } from '../../../services'; + +async function fetchSnapshotPolicies(callAsCurrentUser: LegacyAPICaller): Promise { + const params = { + method: 'GET', + path: '/_slm/policy', + }; + + return await callAsCurrentUser('transport.request', params); +} + +export function registerFetchRoute({ router, license, lib }: RouteDependencies) { + router.get( + { path: addBasePath('/snapshot_policies'), validate: false }, + license.guardApiRoute(async (context, request, response) => { + try { + const policiesByName = await fetchSnapshotPolicies( + context.core.elasticsearch.legacy.client.callAsCurrentUser + ); + return response.ok({ body: Object.keys(policiesByName) }); + } catch (e) { + if (lib.isEsError(e)) { + return response.customError({ + statusCode: e.statusCode, + body: e, + }); + } + // Case: default + return response.internalError({ body: e }); + } + }) + ); +} diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/index.ts b/x-pack/plugins/index_lifecycle_management/server/routes/index.ts index 35996721854c6..f7390debbe177 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/index.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/index.ts @@ -10,10 +10,12 @@ import { registerIndexRoutes } from './api/index'; import { registerNodesRoutes } from './api/nodes'; import { registerPoliciesRoutes } from './api/policies'; import { registerTemplatesRoutes } from './api/templates'; +import { registerSnapshotPoliciesRoutes } from './api/snapshot_policies'; export function registerApiRoutes(dependencies: RouteDependencies) { registerIndexRoutes(dependencies); registerNodesRoutes(dependencies); registerPoliciesRoutes(dependencies); registerTemplatesRoutes(dependencies); + registerSnapshotPoliciesRoutes(dependencies); } diff --git a/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/install.ts deleted file mode 100644 index ae6493d4716e8..0000000000000 --- a/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/install.ts +++ /dev/null @@ -1,119 +0,0 @@ -/* - * 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 { - SavedObject, - SavedObjectsBulkCreateObject, - SavedObjectsClientContract, -} from 'src/core/server'; -import * as Registry from '../../registry'; -import { AssetType, KibanaAssetType, AssetReference } from '../../../../types'; - -type SavedObjectToBe = Required & { type: AssetType }; -export type ArchiveAsset = Pick< - SavedObject, - 'id' | 'attributes' | 'migrationVersion' | 'references' -> & { - type: AssetType; -}; - -export async function getKibanaAsset(key: string) { - const buffer = Registry.getAsset(key); - - // cache values are buffers. convert to string / JSON - return JSON.parse(buffer.toString('utf8')); -} - -export function createSavedObjectKibanaAsset( - jsonAsset: ArchiveAsset, - pkgName: string -): SavedObjectToBe { - // convert that to an object - const asset = changeAssetIds(jsonAsset, pkgName); - - return { - type: asset.type, - id: asset.id, - attributes: asset.attributes, - references: asset.references || [], - migrationVersion: asset.migrationVersion || {}, - }; -} - -// modifies id property and the id property of references objects (not index-pattern) -// to be prepended with the package name to distinguish assets from Beats modules' assets -export const changeAssetIds = (asset: ArchiveAsset, pkgName: string): ArchiveAsset => { - const references = asset.references.map((ref) => { - if (ref.type === KibanaAssetType.indexPattern) return ref; - const id = getAssetId(ref.id, pkgName); - return { ...ref, id }; - }); - return { - ...asset, - id: getAssetId(asset.id, pkgName), - references, - }; -}; - -export const getAssetId = (id: string, pkgName: string) => { - return `${pkgName}-${id}`; -}; - -// TODO: make it an exhaustive list -// e.g. switch statement with cases for each enum key returning `never` for default case -export async function installKibanaAssets(options: { - savedObjectsClient: SavedObjectsClientContract; - pkgName: string; - paths: string[]; -}) { - const { savedObjectsClient, paths, pkgName } = options; - - // Only install Kibana assets during package installation. - const kibanaAssetTypes = Object.values(KibanaAssetType); - const installationPromises = kibanaAssetTypes.map((assetType) => - installKibanaSavedObjects({ savedObjectsClient, assetType, paths, pkgName }) - ); - - // installKibanaSavedObjects returns AssetReference[], so .map creates AssetReference[][] - // call .flat to flatten into one dimensional array - return Promise.all(installationPromises).then((results) => results.flat()); -} - -async function installKibanaSavedObjects({ - savedObjectsClient, - assetType, - paths, - pkgName, -}: { - savedObjectsClient: SavedObjectsClientContract; - assetType: KibanaAssetType; - paths: string[]; - pkgName: string; -}) { - const isSameType = (path: string) => assetType === Registry.pathParts(path).type; - const pathsOfType = paths.filter((path) => isSameType(path)); - const kibanaAssets = await Promise.all(pathsOfType.map((path) => getKibanaAsset(path))); - const toBeSavedObjects = await Promise.all( - kibanaAssets.map((asset) => createSavedObjectKibanaAsset(asset, pkgName)) - ); - - if (toBeSavedObjects.length === 0) { - return []; - } else { - const createResults = await savedObjectsClient.bulkCreate(toBeSavedObjects, { - overwrite: true, - }); - const createdObjects = createResults.saved_objects; - const installed = createdObjects.map(toAssetReference); - return installed; - } -} - -function toAssetReference({ id, type }: SavedObject) { - const reference: AssetReference = { id, type: type as KibanaAssetType }; - - return reference; -} diff --git a/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/tests/__snapshots__/install.test.ts.snap b/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/tests/__snapshots__/install.test.ts.snap deleted file mode 100644 index 638ed4b6118c9..0000000000000 --- a/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/tests/__snapshots__/install.test.ts.snap +++ /dev/null @@ -1,133 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`a kibana asset id and its reference ids are appended with package name changeAssetIds output matches snapshot: dashboard.json 1`] = ` -{ - "attributes": { - "description": "Overview dashboard for the Nginx integration in Metrics", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": { - "filter": [], - "highlightAll": true, - "query": { - "language": "kuery", - "query": "" - }, - "version": true - } - }, - "optionsJSON": { - "darkTheme": false, - "hidePanelTitles": false, - "useMargins": true - }, - "panelsJSON": [ - { - "embeddableConfig": {}, - "gridData": { - "h": 12, - "i": "1", - "w": 24, - "x": 24, - "y": 0 - }, - "panelIndex": "1", - "panelRefName": "panel_0", - "version": "7.3.0" - }, - { - "embeddableConfig": {}, - "gridData": { - "h": 12, - "i": "2", - "w": 24, - "x": 24, - "y": 12 - }, - "panelIndex": "2", - "panelRefName": "panel_1", - "version": "7.3.0" - }, - { - "embeddableConfig": {}, - "gridData": { - "h": 12, - "i": "3", - "w": 24, - "x": 0, - "y": 12 - }, - "panelIndex": "3", - "panelRefName": "panel_2", - "version": "7.3.0" - }, - { - "embeddableConfig": {}, - "gridData": { - "h": 12, - "i": "4", - "w": 24, - "x": 0, - "y": 0 - }, - "panelIndex": "4", - "panelRefName": "panel_3", - "version": "7.3.0" - }, - { - "embeddableConfig": {}, - "gridData": { - "h": 12, - "i": "5", - "w": 48, - "x": 0, - "y": 24 - }, - "panelIndex": "5", - "panelRefName": "panel_4", - "version": "7.3.0" - } - ], - "timeRestore": false, - "title": "[Metrics Nginx] Overview ECS", - "version": 1 - }, - "id": "nginx-023d2930-f1a5-11e7-a9ef-93c69af7b129-ecs", - "migrationVersion": { - "dashboard": "7.3.0" - }, - "references": [ - { - "id": "metrics-*", - "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", - "type": "index-pattern" - }, - { - "id": "nginx-555df8a0-f1a1-11e7-a9ef-93c69af7b129-ecs", - "name": "panel_0", - "type": "search" - }, - { - "id": "nginx-a1d92240-f1a1-11e7-a9ef-93c69af7b129-ecs", - "name": "panel_1", - "type": "map" - }, - { - "id": "nginx-d763a570-f1a1-11e7-a9ef-93c69af7b129-ecs", - "name": "panel_2", - "type": "dashboard" - }, - { - "id": "nginx-47a8e0f0-f1a4-11e7-a9ef-93c69af7b129-ecs", - "name": "panel_3", - "type": "visualization" - }, - { - "id": "nginx-dcbffe30-f1a4-11e7-a9ef-93c69af7b129-ecs", - "name": "panel_4", - "type": "visualization" - } - ], - "type": "dashboard" -} -`; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/tests/dashboard.json b/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/tests/dashboard.json deleted file mode 100644 index e28a61ae5e18c..0000000000000 --- a/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/tests/dashboard.json +++ /dev/null @@ -1,129 +0,0 @@ -{ - "attributes": { - "description": "Overview dashboard for the Nginx integration in Metrics", - "hits": 0, - "kibanaSavedObjectMeta": { - "searchSourceJSON": { - "filter": [], - "highlightAll": true, - "query": { - "language": "kuery", - "query": "" - }, - "version": true - } - }, - "optionsJSON": { - "darkTheme": false, - "hidePanelTitles": false, - "useMargins": true - }, - "panelsJSON": [ - { - "embeddableConfig": {}, - "gridData": { - "h": 12, - "i": "1", - "w": 24, - "x": 24, - "y": 0 - }, - "panelIndex": "1", - "panelRefName": "panel_0", - "version": "7.3.0" - }, - { - "embeddableConfig": {}, - "gridData": { - "h": 12, - "i": "2", - "w": 24, - "x": 24, - "y": 12 - }, - "panelIndex": "2", - "panelRefName": "panel_1", - "version": "7.3.0" - }, - { - "embeddableConfig": {}, - "gridData": { - "h": 12, - "i": "3", - "w": 24, - "x": 0, - "y": 12 - }, - "panelIndex": "3", - "panelRefName": "panel_2", - "version": "7.3.0" - }, - { - "embeddableConfig": {}, - "gridData": { - "h": 12, - "i": "4", - "w": 24, - "x": 0, - "y": 0 - }, - "panelIndex": "4", - "panelRefName": "panel_3", - "version": "7.3.0" - }, - { - "embeddableConfig": {}, - "gridData": { - "h": 12, - "i": "5", - "w": 48, - "x": 0, - "y": 24 - }, - "panelIndex": "5", - "panelRefName": "panel_4", - "version": "7.3.0" - } - ], - "timeRestore": false, - "title": "[Metrics Nginx] Overview ECS", - "version": 1 - }, - "id": "023d2930-f1a5-11e7-a9ef-93c69af7b129-ecs", - "migrationVersion": { - "dashboard": "7.3.0" - }, - "references": [ - { - "id": "metrics-*", - "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", - "type": "index-pattern" - }, - { - "id": "555df8a0-f1a1-11e7-a9ef-93c69af7b129-ecs", - "name": "panel_0", - "type": "search" - }, - { - "id": "a1d92240-f1a1-11e7-a9ef-93c69af7b129-ecs", - "name": "panel_1", - "type": "map" - }, - { - "id": "d763a570-f1a1-11e7-a9ef-93c69af7b129-ecs", - "name": "panel_2", - "type": "dashboard" - }, - { - "id": "47a8e0f0-f1a4-11e7-a9ef-93c69af7b129-ecs", - "name": "panel_3", - "type": "visualization" - }, - { - "id": "dcbffe30-f1a4-11e7-a9ef-93c69af7b129-ecs", - "name": "panel_4", - "type": "visualization" - } - ], - "type": "dashboard" -} \ No newline at end of file diff --git a/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/tests/install.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/tests/install.test.ts deleted file mode 100644 index f9bc4cdbf203f..0000000000000 --- a/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/tests/install.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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 { readFileSync } from 'fs'; -import path from 'path'; -import { getAssetId, changeAssetIds } from '../install'; - -expect.addSnapshotSerializer({ - print(val) { - return JSON.stringify(val, null, 2); - }, - - test(val) { - return val; - }, -}); - -describe('a kibana asset id and its reference ids are appended with package name', () => { - const assetPath = path.join(__dirname, './dashboard.json'); - const kibanaAsset = JSON.parse(readFileSync(assetPath, 'utf-8')); - const pkgName = 'nginx'; - const modifiedAssetObject = changeAssetIds(kibanaAsset, pkgName); - - test('changeAssetIds output matches snapshot', () => { - expect(modifiedAssetObject).toMatchSnapshot(path.basename(assetPath)); - }); - - test('getAssetId', () => { - const id = '47a8e0f0-f1a4-11e7-a9ef-93c69af7b129-ecs'; - expect(getAssetId(id, pkgName)).toBe(`${pkgName}-${id}`); - }); -}); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/get_objects.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/get_objects.ts new file mode 100644 index 0000000000000..b623295c5e060 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/get_objects.ts @@ -0,0 +1,32 @@ +/* + * 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 { SavedObject, SavedObjectsBulkCreateObject } from 'src/core/server'; +import { AssetType } from '../../../types'; +import * as Registry from '../registry'; + +type ArchiveAsset = Pick; +type SavedObjectToBe = Required & { type: AssetType }; + +export async function getObject(key: string) { + const buffer = Registry.getAsset(key); + + // cache values are buffers. convert to string / JSON + const json = buffer.toString('utf8'); + // convert that to an object + const asset: ArchiveAsset = JSON.parse(json); + + const { type, file } = Registry.pathParts(key); + const savedObject: SavedObjectToBe = { + type, + id: file.replace('.json', ''), + attributes: asset.attributes, + references: asset.references || [], + migrationVersion: asset.migrationVersion || {}, + }; + + return savedObject; +} diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts index 57c4f77432455..4bb803dfaf912 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts @@ -23,7 +23,7 @@ export { SearchParams, } from './get'; -export { installPackage, ensureInstalledPackage } from './install'; +export { installKibanaAssets, installPackage, ensureInstalledPackage } from './install'; export { removeInstallation } from './remove'; type RequiredPackage = 'system' | 'endpoint'; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts index 8f73bc9a02765..910283549abdf 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts @@ -4,12 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SavedObjectsClientContract } from 'src/core/server'; +import { SavedObject, SavedObjectsClientContract } from 'src/core/server'; import Boom from 'boom'; import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../constants'; import { AssetReference, Installation, + KibanaAssetType, CallESAsCurrentUser, DefaultPackages, ElasticsearchAssetType, @@ -17,7 +18,7 @@ import { } from '../../../types'; import { installIndexPatterns } from '../kibana/index_pattern/install'; import * as Registry from '../registry'; -import { installKibanaAssets } from '../kibana/assets/install'; +import { getObject } from './get_objects'; import { getInstallation, getInstallationObject, isRequiredPackage } from './index'; import { installTemplates } from '../elasticsearch/template/install'; import { generateESIndexPatterns } from '../elasticsearch/template/template'; @@ -120,6 +121,7 @@ export async function installPackage(options: { installKibanaAssets({ savedObjectsClient, pkgName, + pkgVersion, paths, }), installPipelines(registryPackageInfo, paths, callCluster), @@ -183,6 +185,27 @@ export async function installPackage(options: { }); } +// TODO: make it an exhaustive list +// e.g. switch statement with cases for each enum key returning `never` for default case +export async function installKibanaAssets(options: { + savedObjectsClient: SavedObjectsClientContract; + pkgName: string; + pkgVersion: string; + paths: string[]; +}) { + const { savedObjectsClient, paths } = options; + + // Only install Kibana assets during package installation. + const kibanaAssetTypes = Object.values(KibanaAssetType); + const installationPromises = kibanaAssetTypes.map(async (assetType) => + installKibanaSavedObjects({ savedObjectsClient, assetType, paths }) + ); + + // installKibanaSavedObjects returns AssetReference[], so .map creates AssetReference[][] + // call .flat to flatten into one dimensional array + return Promise.all(installationPromises).then((results) => results.flat()); +} + export async function saveInstallationReferences(options: { savedObjectsClient: SavedObjectsClientContract; pkgName: string; @@ -217,3 +240,34 @@ export async function saveInstallationReferences(options: { return toSaveAssetRefs; } + +async function installKibanaSavedObjects({ + savedObjectsClient, + assetType, + paths, +}: { + savedObjectsClient: SavedObjectsClientContract; + assetType: KibanaAssetType; + paths: string[]; +}) { + const isSameType = (path: string) => assetType === Registry.pathParts(path).type; + const pathsOfType = paths.filter((path) => isSameType(path)); + const toBeSavedObjects = await Promise.all(pathsOfType.map(getObject)); + + if (toBeSavedObjects.length === 0) { + return []; + } else { + const createResults = await savedObjectsClient.bulkCreate(toBeSavedObjects, { + overwrite: true, + }); + const createdObjects = createResults.saved_objects; + const installed = createdObjects.map(toAssetReference); + return installed; + } +} + +function toAssetReference({ id, type }: SavedObject) { + const reference: AssetReference = { id, type: type as KibanaAssetType }; + + return reference; +} diff --git a/x-pack/plugins/lists/server/services/items/create_list_item.test.ts b/x-pack/plugins/lists/server/services/items/create_list_item.test.ts index 7fbdc900fe2a4..76bd47d217107 100644 --- a/x-pack/plugins/lists/server/services/items/create_list_item.test.ts +++ b/x-pack/plugins/lists/server/services/items/create_list_item.test.ts @@ -36,6 +36,7 @@ describe('crete_list_item', () => { body, id: LIST_ITEM_ID, index: LIST_ITEM_INDEX, + refresh: 'wait_for', }; expect(options.callCluster).toBeCalledWith('index', expected); }); diff --git a/x-pack/plugins/lists/server/services/items/create_list_item.ts b/x-pack/plugins/lists/server/services/items/create_list_item.ts index 333f34946828a..aa17fc00b25c6 100644 --- a/x-pack/plugins/lists/server/services/items/create_list_item.ts +++ b/x-pack/plugins/lists/server/services/items/create_list_item.ts @@ -71,6 +71,7 @@ export const createListItem = async ({ body, id, index: listItemIndex, + refresh: 'wait_for', }); return { diff --git a/x-pack/plugins/lists/server/services/items/create_list_items_bulk.test.ts b/x-pack/plugins/lists/server/services/items/create_list_items_bulk.test.ts index 4ab1bfb856846..b2cc0da669e42 100644 --- a/x-pack/plugins/lists/server/services/items/create_list_items_bulk.test.ts +++ b/x-pack/plugins/lists/server/services/items/create_list_items_bulk.test.ts @@ -33,6 +33,7 @@ describe('crete_list_item_bulk', () => { secondRecord, ], index: LIST_ITEM_INDEX, + refresh: 'wait_for', }); }); @@ -70,6 +71,7 @@ describe('crete_list_item_bulk', () => { }, ], index: '.items', + refresh: 'wait_for', }); }); }); diff --git a/x-pack/plugins/lists/server/services/items/create_list_items_bulk.ts b/x-pack/plugins/lists/server/services/items/create_list_items_bulk.ts index 463b9735b2578..91e9587aa676a 100644 --- a/x-pack/plugins/lists/server/services/items/create_list_items_bulk.ts +++ b/x-pack/plugins/lists/server/services/items/create_list_items_bulk.ts @@ -84,6 +84,7 @@ export const createListItemsBulk = async ({ await callCluster('bulk', { body, index: listItemIndex, + refresh: 'wait_for', }); } catch (error) { // TODO: Log out the error with return values from the bulk insert into another index or saved object diff --git a/x-pack/plugins/lists/server/services/items/delete_list_item.test.ts b/x-pack/plugins/lists/server/services/items/delete_list_item.test.ts index ea338d9dd3791..b14bddb1268f8 100644 --- a/x-pack/plugins/lists/server/services/items/delete_list_item.test.ts +++ b/x-pack/plugins/lists/server/services/items/delete_list_item.test.ts @@ -47,6 +47,7 @@ describe('delete_list_item', () => { const deleteQuery = { id: LIST_ITEM_ID, index: LIST_ITEM_INDEX, + refresh: 'wait_for', }; expect(options.callCluster).toBeCalledWith('delete', deleteQuery); }); diff --git a/x-pack/plugins/lists/server/services/items/delete_list_item.ts b/x-pack/plugins/lists/server/services/items/delete_list_item.ts index b006aed6f6dde..baeced4b09995 100644 --- a/x-pack/plugins/lists/server/services/items/delete_list_item.ts +++ b/x-pack/plugins/lists/server/services/items/delete_list_item.ts @@ -28,6 +28,7 @@ export const deleteListItem = async ({ await callCluster('delete', { id, index: listItemIndex, + refresh: 'wait_for', }); } return listItem; diff --git a/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.test.ts b/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.test.ts index bf1608334ef24..f658a51730d97 100644 --- a/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.test.ts +++ b/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.test.ts @@ -52,6 +52,7 @@ describe('delete_list_item_by_value', () => { }, }, index: '.items', + refresh: 'wait_for', }; expect(options.callCluster).toBeCalledWith('deleteByQuery', deleteByQuery); }); diff --git a/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.ts b/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.ts index 3551cb75dc5bc..880402fca1bfa 100644 --- a/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.ts +++ b/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.ts @@ -48,6 +48,7 @@ export const deleteListItemByValue = async ({ }, }, index: listItemIndex, + refresh: 'wait_for', }); return listItems; }; diff --git a/x-pack/plugins/lists/server/services/items/update_list_item.ts b/x-pack/plugins/lists/server/services/items/update_list_item.ts index 24cd11cbb65e4..eb20f1cfe3b30 100644 --- a/x-pack/plugins/lists/server/services/items/update_list_item.ts +++ b/x-pack/plugins/lists/server/services/items/update_list_item.ts @@ -62,6 +62,7 @@ export const updateListItem = async ({ }, id: listItem.id, index: listItemIndex, + refresh: 'wait_for', }); return { created_at: listItem.created_at, diff --git a/x-pack/plugins/lists/server/services/lists/create_list.test.ts b/x-pack/plugins/lists/server/services/lists/create_list.test.ts index 43af08bcaf7ff..e328df710ebe1 100644 --- a/x-pack/plugins/lists/server/services/lists/create_list.test.ts +++ b/x-pack/plugins/lists/server/services/lists/create_list.test.ts @@ -52,6 +52,7 @@ describe('crete_list', () => { body, id: LIST_ID, index: LIST_INDEX, + refresh: 'wait_for', }; expect(options.callCluster).toBeCalledWith('index', expected); }); diff --git a/x-pack/plugins/lists/server/services/lists/create_list.ts b/x-pack/plugins/lists/server/services/lists/create_list.ts index 3925fa5f0170c..3d396cf4d5af9 100644 --- a/x-pack/plugins/lists/server/services/lists/create_list.ts +++ b/x-pack/plugins/lists/server/services/lists/create_list.ts @@ -67,6 +67,7 @@ export const createList = async ({ body, id, index: listIndex, + refresh: 'wait_for', }); return { id: response._id, diff --git a/x-pack/plugins/lists/server/services/lists/delete_list.test.ts b/x-pack/plugins/lists/server/services/lists/delete_list.test.ts index b9f1ec4d400be..029b6226a7375 100644 --- a/x-pack/plugins/lists/server/services/lists/delete_list.test.ts +++ b/x-pack/plugins/lists/server/services/lists/delete_list.test.ts @@ -47,6 +47,7 @@ describe('delete_list', () => { const deleteByQuery = { body: { query: { term: { list_id: LIST_ID } } }, index: LIST_ITEM_INDEX, + refresh: 'wait_for', }; expect(options.callCluster).toBeCalledWith('deleteByQuery', deleteByQuery); }); @@ -59,6 +60,7 @@ describe('delete_list', () => { const deleteQuery = { id: LIST_ID, index: LIST_INDEX, + refresh: 'wait_for', }; expect(options.callCluster).toHaveBeenNthCalledWith(2, 'delete', deleteQuery); }); diff --git a/x-pack/plugins/lists/server/services/lists/delete_list.ts b/x-pack/plugins/lists/server/services/lists/delete_list.ts index 64359b7273274..152048ca9cac6 100644 --- a/x-pack/plugins/lists/server/services/lists/delete_list.ts +++ b/x-pack/plugins/lists/server/services/lists/delete_list.ts @@ -36,11 +36,13 @@ export const deleteList = async ({ }, }, index: listItemIndex, + refresh: 'wait_for', }); await callCluster('delete', { id, index: listIndex, + refresh: 'wait_for', }); return list; } diff --git a/x-pack/plugins/lists/server/services/lists/update_list.ts b/x-pack/plugins/lists/server/services/lists/update_list.ts index c7cc30aaae908..f84ca787eaa7c 100644 --- a/x-pack/plugins/lists/server/services/lists/update_list.ts +++ b/x-pack/plugins/lists/server/services/lists/update_list.ts @@ -55,6 +55,7 @@ export const updateList = async ({ body: { doc }, id, index: listIndex, + refresh: 'wait_for', }); return { created_at: list.created_at, diff --git a/x-pack/plugins/ml/public/application/components/navigation_menu/main_tabs.tsx b/x-pack/plugins/ml/public/application/components/navigation_menu/main_tabs.tsx index 859d649416267..3a4875fa243fd 100644 --- a/x-pack/plugins/ml/public/application/components/navigation_menu/main_tabs.tsx +++ b/x-pack/plugins/ml/public/application/components/navigation_menu/main_tabs.tsx @@ -60,7 +60,7 @@ function getTabs(disableLinks: boolean): Tab[] { name: i18n.translate('xpack.ml.navMenu.settingsTabLinkText', { defaultMessage: 'Settings', }), - disabled: false, + disabled: disableLinks, }, ]; } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx index e75d938116991..cb46a88fa3b21 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx @@ -30,17 +30,21 @@ export const useActions = ( actions: EuiTableActionsColumnType['actions']; modals: JSX.Element | null; } => { - const deleteAction = useDeleteAction(); - const editAction = useEditAction(); - const startAction = useStartAction(); - let modals: JSX.Element | null = null; const actions: EuiTableActionsColumnType['actions'] = [ getViewAction(isManagementTable), ]; + // isManagementTable will be the same for the lifecycle of the component + // Disabling lint error to fix console error in management list due to action hooks using deps not initialized in management if (isManagementTable === false) { + /* eslint-disable react-hooks/rules-of-hooks */ + const deleteAction = useDeleteAction(); + const editAction = useEditAction(); + const startAction = useStartAction(); + /* eslint-disable react-hooks/rules-of-hooks */ + modals = ( <> {startAction.isModalVisible && } diff --git a/x-pack/plugins/observability/public/assets/illustration_dark.svg b/x-pack/plugins/observability/public/assets/illustration_dark.svg new file mode 100644 index 0000000000000..44815a7455144 --- /dev/null +++ b/x-pack/plugins/observability/public/assets/illustration_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/x-pack/plugins/observability/public/assets/illustration_light.svg b/x-pack/plugins/observability/public/assets/illustration_light.svg new file mode 100644 index 0000000000000..1690c68fd595a --- /dev/null +++ b/x-pack/plugins/observability/public/assets/illustration_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/x-pack/plugins/observability/public/assets/observability_overview.png b/x-pack/plugins/observability/public/assets/observability_overview.png deleted file mode 100644 index 70be08af9745a..0000000000000 Binary files a/x-pack/plugins/observability/public/assets/observability_overview.png and /dev/null differ diff --git a/x-pack/plugins/observability/public/components/app/news/index.test.tsx b/x-pack/plugins/observability/public/components/app/news/index.test.tsx deleted file mode 100644 index cae6b4aec0c62..0000000000000 --- a/x-pack/plugins/observability/public/components/app/news/index.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -/* - * 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 from 'react'; -import { render } from '../../../utils/test_helper'; -import { News } from './'; - -describe('News', () => { - it('renders resources with all elements', () => { - const { getByText, getAllByText } = render(); - expect(getByText("What's new")).toBeInTheDocument(); - expect(getAllByText('Read full story')).not.toEqual([]); - }); -}); diff --git a/x-pack/plugins/observability/public/components/app/news/mock/news.mock.data.ts b/x-pack/plugins/observability/public/components/app/news/mock/news.mock.data.ts deleted file mode 100644 index 5c623bb9134eb..0000000000000 --- a/x-pack/plugins/observability/public/components/app/news/mock/news.mock.data.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * 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. - */ - -export const news = [ - { - title: 'Have SIEM questions?', - description: - 'Join our growing community of Elastic SIEM users to discuss the configuration and use of Elastic SIEM for threat detection and response.', - link_url: 'https://discuss.elastic.co/c/security/siem/?blade=securitysolutionfeed', - image_url: - 'https://aws1.discourse-cdn.com/elastic/original/3X/f/8/f8c3d0b9971cfcd0be349d973aa5799f71d280cc.png?blade=securitysolutionfeed', - }, - { - title: 'Elastic SIEM on-demand training course — free for a limited time', - description: - 'With this self-paced, on-demand course, you will learn how to leverage Elastic SIEM to drive your security operations and threat hunting. This course is designed for security analysts and practitioners who have used other SIEMs or are familiar with SIEM concepts.', - link_url: - 'https://training.elastic.co/elearning/security-analytics/elastic-siem-fundamentals-promo?blade=securitysolutionfeed', - image_url: - 'https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt50f58e0358ebea9d/5c30508693d9791a70cd73ad/illustration-specialization-course-page-security.svg?blade=securitysolutionfeed', - }, - { - title: 'New to Elastic SIEM? Take our on-demand training course', - description: - 'With this self-paced, on-demand course, you will learn how to leverage Elastic SIEM to drive your security operations and threat hunting. This course is designed for security analysts and practitioners who have used other SIEMs or are familiar with SIEM concepts.', - link_url: - 'https://www.elastic.co/training/specializations/security-analytics/elastic-siem-fundamentals?blade=securitysolutionfeed', - image_url: - 'https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt50f58e0358ebea9d/5c30508693d9791a70cd73ad/illustration-specialization-course-page-security.svg?blade=securitysolutionfeed', - }, -]; diff --git a/x-pack/plugins/observability/public/components/app/news/index.scss b/x-pack/plugins/observability/public/components/app/news_feed/index.scss similarity index 100% rename from x-pack/plugins/observability/public/components/app/news/index.scss rename to x-pack/plugins/observability/public/components/app/news_feed/index.scss diff --git a/x-pack/plugins/observability/public/components/app/news_feed/index.test.tsx b/x-pack/plugins/observability/public/components/app/news_feed/index.test.tsx new file mode 100644 index 0000000000000..c71130b57c33f --- /dev/null +++ b/x-pack/plugins/observability/public/components/app/news_feed/index.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { NewsItem } from '../../../services/get_news_feed'; +import { render } from '../../../utils/test_helper'; +import { NewsFeed } from './'; + +const newsFeedItems = [ + { + title: { + en: 'Elastic introduces OpenTelemetry integration', + }, + description: { + en: + 'We are pleased to announce the availability of the Elastic OpenTelemetry integration — available on Elastic Cloud, or when you download Elastic APM.', + }, + link_url: { + en: + 'https://www.elastic.co/blog/elastic-apm-opentelemetry-integration?blade=observabilitysolutionfeed', + }, + image_url: { + en: 'foo.png', + }, + }, + { + title: { + en: 'Kubernetes observability tutorial: Log monitoring and analysis', + }, + description: { + en: + 'Learn how Elastic Observability makes it easy to monitor and detect anomalies in millions of logs from thousands of containers running hundreds of microservices — while Kubernetes scales applications with changing pod counts. All from a single UI.', + }, + link_url: { + en: + 'https://www.elastic.co/blog/kubernetes-observability-tutorial-k8s-log-monitoring-and-analysis-elastic-stack?blade=observabilitysolutionfeed', + }, + image_url: null, + }, + { + title: { + en: 'Kubernetes observability tutorial: K8s cluster setup and demo app deployment', + }, + description: { + en: + 'This blog will walk you through configuring the environment you will be using for the Kubernetes observability tutorial blog series. We will be deploying Elasticsearch Service, a Minikube single-node Kubernetes cluster setup, and a demo app.', + }, + link_url: { + en: + 'https://www.elastic.co/blog/kubernetes-observability-tutorial-k8s-cluster-setup-demo-app-deployment?blade=observabilitysolutionfeed', + }, + image_url: { + en: null, + }, + }, +] as NewsItem[]; +describe('News', () => { + it('renders resources with all elements', () => { + const { getByText, getAllByText, queryAllByTestId } = render( + + ); + expect(getByText("What's new")).toBeInTheDocument(); + expect(getAllByText('Read full story').length).toEqual(3); + expect(queryAllByTestId('news_image').length).toEqual(1); + }); +}); diff --git a/x-pack/plugins/observability/public/components/app/news/index.tsx b/x-pack/plugins/observability/public/components/app/news_feed/index.tsx similarity index 53% rename from x-pack/plugins/observability/public/components/app/news/index.tsx rename to x-pack/plugins/observability/public/components/app/news_feed/index.tsx index 41a4074f47976..2fbd6659bcb5a 100644 --- a/x-pack/plugins/observability/public/components/app/news/index.tsx +++ b/x-pack/plugins/observability/public/components/app/news_feed/index.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { + EuiErrorBoundary, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, @@ -12,51 +13,51 @@ import { EuiTitle, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { truncate } from 'lodash'; import React, { useContext } from 'react'; import { ThemeContext } from 'styled-components'; +import { NewsItem as INewsItem } from '../../../services/get_news_feed'; import './index.scss'; -import { truncate } from 'lodash'; -import { news as newsMockData } from './mock/news.mock.data'; -interface NewsItem { - title: string; - description: string; - link_url: string; - image_url: string; +interface Props { + items: INewsItem[]; } -export const News = () => { - const newsItems: NewsItem[] = newsMockData; +export const NewsFeed = ({ items }: Props) => { return ( - - - -

- {i18n.translate('xpack.observability.news.title', { - defaultMessage: "What's new", - })} -

-
-
- {newsItems.map((item, index) => ( - - + // The news feed is manually added/edited, to prevent any errors caused by typos or missing fields, + // wraps the component with EuiErrorBoundary to avoid breaking the entire page. + + + + +

+ {i18n.translate('xpack.observability.news.title', { + defaultMessage: "What's new", + })} +

+
- ))} -
+ {items.map((item, index) => ( + + + + ))} +
+ ); }; const limitString = (string: string, limit: number) => truncate(string, { length: limit }); -const NewsItem = ({ item }: { item: NewsItem }) => { +const NewsItem = ({ item }: { item: INewsItem }) => { const theme = useContext(ThemeContext); return ( -

{item.title}

+

{item.title.en}

@@ -65,11 +66,11 @@ const NewsItem = ({ item }: { item: NewsItem }) => { - {limitString(item.description, 128)} + {limitString(item.description.en, 128)} - + {i18n.translate('xpack.observability.news.readFullStory', { defaultMessage: 'Read full story', @@ -79,16 +80,19 @@ const NewsItem = ({ item }: { item: NewsItem }) => { - - {item.title} - + {item.image_url?.en && ( + + {item.title.en} + + )}
diff --git a/x-pack/plugins/observability/public/pages/landing/index.tsx b/x-pack/plugins/observability/public/pages/landing/index.tsx index b614095641250..512f4428d9bf2 100644 --- a/x-pack/plugins/observability/public/pages/landing/index.tsx +++ b/x-pack/plugins/observability/public/pages/landing/index.tsx @@ -84,7 +84,9 @@ export const LandingPage = () => { size="xl" alt="observability overview image" url={core.http.basePath.prepend( - '/plugins/observability/assets/observability_overview.png' + `/plugins/observability/assets/illustration_${ + theme.darkMode ? 'dark' : 'light' + }.svg` )} /> diff --git a/x-pack/plugins/observability/public/pages/overview/index.tsx b/x-pack/plugins/observability/public/pages/overview/index.tsx index 9caac7f9d86f4..3674e69ab5702 100644 --- a/x-pack/plugins/observability/public/pages/overview/index.tsx +++ b/x-pack/plugins/observability/public/pages/overview/index.tsx @@ -16,6 +16,7 @@ import { LogsSection } from '../../components/app/section/logs'; import { MetricsSection } from '../../components/app/section/metrics'; import { UptimeSection } from '../../components/app/section/uptime'; import { DatePicker, TimePickerTime } from '../../components/shared/data_picker'; +import { NewsFeed } from '../../components/app/news_feed'; import { fetchHasData } from '../../data_handler'; import { FETCH_STATUS, useFetcher } from '../../hooks/use_fetcher'; import { UI_SETTINGS, useKibanaUISettings } from '../../hooks/use_kibana_ui_settings'; @@ -26,6 +27,7 @@ import { getParsedDate } from '../../utils/date'; import { getBucketSize } from '../../utils/get_bucket_size'; import { getEmptySections } from './empty_section'; import { LoadingObservability } from './loading_observability'; +import { getNewsFeed } from '../../services/get_news_feed'; interface Props { routeParams: RouteParams<'/overview'>; @@ -48,6 +50,8 @@ export const OverviewPage = ({ routeParams }: Props) => { return getObservabilityAlerts({ core }); }, []); + const { data: newsFeed } = useFetcher(() => getNewsFeed({ core }), []); + const theme = useContext(ThemeContext); const timePickerTime = useKibanaUISettings(UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS); @@ -190,6 +194,12 @@ export const OverviewPage = ({ routeParams }: Props) => { + + {!!newsFeed?.items?.length && ( + + + + )} diff --git a/x-pack/plugins/observability/public/pages/overview/mock/news_feed.mock.ts b/x-pack/plugins/observability/public/pages/overview/mock/news_feed.mock.ts new file mode 100644 index 0000000000000..b23d095e2775b --- /dev/null +++ b/x-pack/plugins/observability/public/pages/overview/mock/news_feed.mock.ts @@ -0,0 +1,72 @@ +/* + * 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. + */ + +export const newsFeedFetchData = async () => { + return { + items: [ + { + title: { + en: 'Elastic introduces OpenTelemetry integration', + }, + description: { + en: + 'We are pleased to announce the availability of the Elastic OpenTelemetry integration — available on Elastic Cloud, or when you download Elastic APM.', + }, + link_text: null, + link_url: { + en: + 'https://www.elastic.co/blog/elastic-apm-opentelemetry-integration?blade=observabilitysolutionfeed', + }, + languages: null, + badge: null, + image_url: null, + publish_on: '2020-07-02T00:00:00', + expire_on: '2021-05-02T00:00:00', + hash: '012caf3e161127d618ae8cc95e3e63f009a45d343eedf2f5e369cc95b1f9d9d3', + }, + { + title: { + en: 'Kubernetes observability tutorial: Log monitoring and analysis', + }, + description: { + en: + 'Learn how Elastic Observability makes it easy to monitor and detect anomalies in millions of logs from thousands of containers running hundreds of microservices — while Kubernetes scales applications with changing pod counts. All from a single UI.', + }, + link_text: null, + link_url: { + en: + 'https://www.elastic.co/blog/kubernetes-observability-tutorial-k8s-log-monitoring-and-analysis-elastic-stack?blade=observabilitysolutionfeed', + }, + languages: null, + badge: null, + image_url: null, + publish_on: '2020-06-23T00:00:00', + expire_on: '2021-06-23T00:00:00', + hash: '79a28cb9be717e82df80bf32c27e5d475e56d0d315be694b661d133f9a58b3b3', + }, + { + title: { + en: 'Kubernetes observability tutorial: K8s cluster setup and demo app deployment', + }, + description: { + en: + 'This blog will walk you through configuring the environment you will be using for the Kubernetes observability tutorial blog series. We will be deploying Elasticsearch Service, a Minikube single-node Kubernetes cluster setup, and a demo app.', + }, + link_text: null, + link_url: { + en: + 'https://www.elastic.co/blog/kubernetes-observability-tutorial-k8s-cluster-setup-demo-app-deployment?blade=observabilitysolutionfeed', + }, + languages: null, + badge: null, + image_url: null, + publish_on: '2020-06-23T00:00:00', + expire_on: '2021-06-23T00:00:00', + hash: 'ad682c355af3d4470a14df116df3b441e941661b291cdac62335615e7c6f13c2', + }, + ], + }; +}; diff --git a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx index b88614b22e81a..896cad7b72ecd 100644 --- a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx +++ b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx @@ -17,6 +17,7 @@ import { fetchUptimeData, emptyResponse as emptyUptimeResponse } from './mock/up import { EuiThemeProvider } from '../../typings'; import { OverviewPage } from './'; import { alertsFetchData } from './mock/alerts.mock'; +import { newsFeedFetchData } from './mock/news_feed.mock'; const core = { http: { @@ -102,6 +103,14 @@ const coreWithAlerts = ({ }, } as unknown) as AppMountContext['core']; +const coreWithNewsFeed = ({ + ...core, + http: { + ...core.http, + get: newsFeedFetchData, + }, +} as unknown) as AppMountContext['core']; + function unregisterAll() { unregisterDataHandler({ appName: 'apm' }); unregisterDataHandler({ appName: 'infra_logs' }); @@ -337,6 +346,45 @@ storiesOf('app/Overview', module) ); }); +storiesOf('app/Overview', module) + .addDecorator((storyFn) => ( + + + {storyFn()}) + + + )) + .add('logs, metrics, APM, Uptime and News feed', () => { + unregisterAll(); + registerDataHandler({ + appName: 'apm', + fetchData: fetchApmData, + hasData: async () => true, + }); + registerDataHandler({ + appName: 'infra_logs', + fetchData: fetchLogsData, + hasData: async () => true, + }); + registerDataHandler({ + appName: 'infra_metrics', + fetchData: fetchMetricsData, + hasData: async () => true, + }); + registerDataHandler({ + appName: 'uptime', + fetchData: fetchUptimeData, + hasData: async () => true, + }); + return ( + + ); + }); + storiesOf('app/Overview', module) .addDecorator((storyFn) => ( diff --git a/x-pack/plugins/observability/public/services/get_news_feed.test.ts b/x-pack/plugins/observability/public/services/get_news_feed.test.ts new file mode 100644 index 0000000000000..49eb2da803ab6 --- /dev/null +++ b/x-pack/plugins/observability/public/services/get_news_feed.test.ts @@ -0,0 +1,98 @@ +/* + * 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 { getNewsFeed } from './get_news_feed'; +import { AppMountContext } from 'kibana/public'; + +describe('getNewsFeed', () => { + it('Returns empty array when api throws exception', async () => { + const core = ({ + http: { + get: async () => { + throw new Error('Boom'); + }, + }, + } as unknown) as AppMountContext['core']; + + const newsFeed = await getNewsFeed({ core }); + expect(newsFeed.items).toEqual([]); + }); + it('Returns array with the news feed', async () => { + const core = ({ + http: { + get: async () => { + return { + items: [ + { + title: { + en: 'Elastic introduces OpenTelemetry integration', + }, + description: { + en: + 'We are pleased to announce the availability of the Elastic OpenTelemetry integration — available on Elastic Cloud, or when you download Elastic APM.', + }, + link_text: null, + link_url: { + en: + 'https://www.elastic.co/blog/elastic-apm-opentelemetry-integration?blade=observabilitysolutionfeed', + }, + languages: null, + badge: null, + image_url: null, + publish_on: '2020-07-02T00:00:00', + expire_on: '2021-05-02T00:00:00', + hash: '012caf3e161127d618ae8cc95e3e63f009a45d343eedf2f5e369cc95b1f9d9d3', + }, + { + title: { + en: 'Kubernetes observability tutorial: Log monitoring and analysis', + }, + description: { + en: + 'Learn how Elastic Observability makes it easy to monitor and detect anomalies in millions of logs from thousands of containers running hundreds of microservices — while Kubernetes scales applications with changing pod counts. All from a single UI.', + }, + link_text: null, + link_url: { + en: + 'https://www.elastic.co/blog/kubernetes-observability-tutorial-k8s-log-monitoring-and-analysis-elastic-stack?blade=observabilitysolutionfeed', + }, + languages: null, + badge: null, + image_url: null, + publish_on: '2020-06-23T00:00:00', + expire_on: '2021-06-23T00:00:00', + hash: '79a28cb9be717e82df80bf32c27e5d475e56d0d315be694b661d133f9a58b3b3', + }, + { + title: { + en: + 'Kubernetes observability tutorial: K8s cluster setup and demo app deployment', + }, + description: { + en: + 'This blog will walk you through configuring the environment you will be using for the Kubernetes observability tutorial blog series. We will be deploying Elasticsearch Service, a Minikube single-node Kubernetes cluster setup, and a demo app.', + }, + link_text: null, + link_url: { + en: + 'https://www.elastic.co/blog/kubernetes-observability-tutorial-k8s-cluster-setup-demo-app-deployment?blade=observabilitysolutionfeed', + }, + languages: null, + badge: null, + image_url: null, + publish_on: '2020-06-23T00:00:00', + expire_on: '2021-06-23T00:00:00', + hash: 'ad682c355af3d4470a14df116df3b441e941661b291cdac62335615e7c6f13c2', + }, + ], + }; + }, + }, + } as unknown) as AppMountContext['core']; + + const newsFeed = await getNewsFeed({ core }); + expect(newsFeed.items.length).toEqual(3); + }); +}); diff --git a/x-pack/plugins/observability/public/services/get_news_feed.ts b/x-pack/plugins/observability/public/services/get_news_feed.ts new file mode 100644 index 0000000000000..3a6e60fa74188 --- /dev/null +++ b/x-pack/plugins/observability/public/services/get_news_feed.ts @@ -0,0 +1,27 @@ +/* + * 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 { AppMountContext } from 'kibana/public'; + +export interface NewsItem { + title: { en: string }; + description: { en: string }; + link_url: { en: string }; + image_url?: { en: string } | null; +} + +interface NewsFeed { + items: NewsItem[]; +} + +export async function getNewsFeed({ core }: { core: AppMountContext['core'] }): Promise { + try { + return await core.http.get('https://feeds.elastic.co/observability-solution/v8.0.0.json'); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error while fetching news feed', e); + return { items: [] }; + } +} diff --git a/x-pack/plugins/observability/public/services/get_observability_alerts.ts b/x-pack/plugins/observability/public/services/get_observability_alerts.ts index 1bbabbad2834a..49855a30c16f6 100644 --- a/x-pack/plugins/observability/public/services/get_observability_alerts.ts +++ b/x-pack/plugins/observability/public/services/get_observability_alerts.ts @@ -22,6 +22,8 @@ export async function getObservabilityAlerts({ core }: { core: AppMountContext[' ); }); } catch (e) { + // eslint-disable-next-line no-console + console.error('Error while fetching alerts', e); return []; } } diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/index.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/index.ts index 961a046c846e4..9a9f445de0b13 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/index.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/index.ts @@ -28,9 +28,9 @@ export { runTaskFnFactory } from './server/execute_job'; export const getExportType = (): ExportTypeDefinition< JobParamsPanelCsv, - ImmediateCreateJobFn, + ImmediateCreateJobFn, JobParamsPanelCsv, - ImmediateExecuteFn + ImmediateExecuteFn > => ({ ...metadata, jobType: CSV_FROM_SAVEDOBJECT_JOB_TYPE, diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job/index.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job/index.ts index dafac04017607..da9810b03aff6 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job/index.ts @@ -20,15 +20,15 @@ import { } from '../../types'; import { createJobSearch } from './create_job_search'; -export type ImmediateCreateJobFn = ( - jobParams: JobParamsType, +export type ImmediateCreateJobFn = ( + jobParams: JobParamsPanelCsv, headers: KibanaRequest['headers'], context: RequestHandlerContext, req: KibanaRequest ) => Promise<{ type: string | null; title: string; - jobParams: JobParamsType; + jobParams: JobParamsPanelCsv; }>; interface VisData { @@ -37,9 +37,10 @@ interface VisData { panel: SearchPanel; } -export const scheduleTaskFnFactory: ScheduleTaskFnFactory> = function createJobFactoryFn(reporting, parentLogger) { +export const scheduleTaskFnFactory: ScheduleTaskFnFactory = function createJobFactoryFn( + reporting, + parentLogger +) { const config = reporting.getConfig(); const crypto = cryptoFactory(config.get('encryptionKey')); const logger = parentLogger.clone([CSV_FROM_SAVEDOBJECT_JOB_TYPE, 'create-job']); diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/execute_job.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/execute_job.ts index 26b7a24907f40..912ae0809cf92 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/execute_job.ts @@ -7,39 +7,43 @@ import { i18n } from '@kbn/i18n'; import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; import { CONTENT_TYPE_CSV, CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../../common/constants'; -import { cryptoFactory } from '../../../lib'; import { RunTaskFnFactory, ScheduledTaskParams, TaskRunResult } from '../../../types'; import { CsvResultFromSearch } from '../../csv/types'; -import { FakeRequest, JobParamsPanelCsv, SearchPanel } from '../types'; +import { JobParamsPanelCsv, SearchPanel } from '../types'; import { createGenerateCsv } from './lib'; +/* + * The run function receives the full request which provides the un-encrypted + * headers, so encrypted headers are not part of these kind of job params + */ +type ImmediateJobParams = Omit, 'headers'>; + /* * ImmediateExecuteFn receives the job doc payload because the payload was * generated in the ScheduleFn */ -export type ImmediateExecuteFn = ( +export type ImmediateExecuteFn = ( jobId: null, - job: ScheduledTaskParams, + job: ImmediateJobParams, context: RequestHandlerContext, req: KibanaRequest ) => Promise; -export const runTaskFnFactory: RunTaskFnFactory> = function executeJobFactoryFn(reporting, parentLogger) { - const config = reporting.getConfig(); - const crypto = cryptoFactory(config.get('encryptionKey')); +export const runTaskFnFactory: RunTaskFnFactory = function executeJobFactoryFn( + reporting, + parentLogger +) { const logger = parentLogger.clone([CSV_FROM_SAVEDOBJECT_JOB_TYPE, 'execute-job']); const generateCsv = createGenerateCsv(reporting, parentLogger); - return async function runTask(jobId: string | null, job, context, req) { + return async function runTask(jobId: string | null, job, context, request) { // There will not be a jobID for "immediate" generation. // jobID is only for "queued" jobs // Use the jobID as a logging tag or "immediate" const jobLogger = logger.clone([jobId === null ? 'immediate' : jobId]); const { jobParams } = job; - const { isImmediate, panel, visType } = jobParams as JobParamsPanelCsv & { panel: SearchPanel }; + const { panel, visType } = jobParams as JobParamsPanelCsv & { panel: SearchPanel }; if (!panel) { i18n.translate( @@ -50,54 +54,13 @@ export const runTaskFnFactory: RunTaskFnFactory; - const serializedEncryptedHeaders = job.headers; - try { - if (typeof serializedEncryptedHeaders !== 'string') { - throw new Error( - i18n.translate( - 'xpack.reporting.exportTypes.csv_from_savedobject.executeJob.missingJobHeadersErrorMessage', - { - defaultMessage: 'Job headers are missing', - } - ) - ); - } - decryptedHeaders = (await crypto.decrypt(serializedEncryptedHeaders)) as Record< - string, - unknown - >; - } catch (err) { - jobLogger.error(err); - throw new Error( - i18n.translate( - 'xpack.reporting.exportTypes.csv_from_savedobject.executeJob.failedToDecryptReportJobDataErrorMessage', - { - defaultMessage: - 'Failed to decrypt report job data. Please ensure that {encryptionKey} is set and re-generate this report. {err}', - values: { encryptionKey: 'xpack.reporting.encryptionKey', err }, - } - ) - ); - } - - requestObject = { headers: decryptedHeaders }; - } - let content: string; let maxSizeReached = false; let size = 0; try { const generateResults: CsvResultFromSearch = await generateCsv( context, - requestObject, + request, visType as string, panel, jobParams diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/types.d.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/types.d.ts index 835b352953dfe..c182fe49a31f6 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/types.d.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/types.d.ts @@ -23,10 +23,6 @@ export interface JobParamsPanelCsv { visType?: string; } -export interface ScheduledTaskParamsPanelCsv extends ScheduledTaskParams { - jobParams: JobParamsPanelCsv; -} - export interface SavedObjectServiceError { statusCode: number; error?: string; diff --git a/x-pack/plugins/reporting/server/routes/generate_from_savedobject.ts b/x-pack/plugins/reporting/server/routes/generate_from_savedobject.ts deleted file mode 100644 index b8326406743b7..0000000000000 --- a/x-pack/plugins/reporting/server/routes/generate_from_savedobject.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * 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 { schema } from '@kbn/config-schema'; -import { get } from 'lodash'; -import { HandlerErrorFunction, HandlerFunction, QueuedJobPayload } from './types'; -import { ReportingCore } from '../'; -import { API_BASE_GENERATE_V1, CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../common/constants'; -import { getJobParamsFromRequest } from '../export_types/csv_from_savedobject/server/lib/get_job_params_from_request'; -import { authorizedUserPreRoutingFactory } from './lib/authorized_user_pre_routing'; - -/* - * This function registers API Endpoints for queuing Reporting jobs. The API inputs are: - * - saved object type and ID - * - time range and time zone - * - application state: - * - filters - * - query bar - * - local (transient) changes the user made to the saved object - */ -export function registerGenerateCsvFromSavedObject( - reporting: ReportingCore, - handleRoute: HandlerFunction, - handleRouteError: HandlerErrorFunction -) { - const setupDeps = reporting.getPluginSetupDeps(); - const userHandler = authorizedUserPreRoutingFactory(reporting); - const { router } = setupDeps; - router.post( - { - path: `${API_BASE_GENERATE_V1}/csv/saved-object/{savedObjectType}:{savedObjectId}`, - validate: { - params: schema.object({ - savedObjectType: schema.string({ minLength: 2 }), - savedObjectId: schema.string({ minLength: 2 }), - }), - body: schema.object({ - state: schema.object({}), - timerange: schema.object({ - timezone: schema.string({ defaultValue: 'UTC' }), - min: schema.nullable(schema.oneOf([schema.number(), schema.string({ minLength: 5 })])), - max: schema.nullable(schema.oneOf([schema.number(), schema.string({ minLength: 5 })])), - }), - }), - }, - }, - userHandler(async (user, context, req, res) => { - /* - * 1. Build `jobParams` object: job data that execution will need to reference in various parts of the lifecycle - * 2. Pass the jobParams and other common params to `handleRoute`, a shared function to enqueue the job with the params - * 3. Ensure that details for a queued job were returned - */ - let result: QueuedJobPayload; - try { - const jobParams = getJobParamsFromRequest(req, { isImmediate: false }); - result = await handleRoute( - user, - CSV_FROM_SAVEDOBJECT_JOB_TYPE, - jobParams, - context, - req, - res - ); - } catch (err) { - return handleRouteError(res, err); - } - - if (get(result, 'source.job') == null) { - return res.badRequest({ - body: `The Export handler is expected to return a result with job info! ${result}`, - }); - } - - return res.ok({ - body: result, - headers: { - 'content-type': 'application/json', - }, - }); - }) - ); -} diff --git a/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts b/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts index 7d93a36c85bc8..97441bba70984 100644 --- a/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts +++ b/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts @@ -10,7 +10,6 @@ import { API_BASE_GENERATE_V1 } from '../../common/constants'; import { scheduleTaskFnFactory } from '../export_types/csv_from_savedobject/server/create_job'; import { runTaskFnFactory } from '../export_types/csv_from_savedobject/server/execute_job'; import { getJobParamsFromRequest } from '../export_types/csv_from_savedobject/server/lib/get_job_params_from_request'; -import { ScheduledTaskParamsPanelCsv } from '../export_types/csv_from_savedobject/types'; import { LevelLogger as Logger } from '../lib'; import { TaskRunResult } from '../types'; import { authorizedUserPreRoutingFactory } from './lib/authorized_user_pre_routing'; @@ -64,12 +63,8 @@ export function registerGenerateCsvFromSavedObjectImmediate( const runTaskFn = runTaskFnFactory(reporting, logger); try { - const jobDocPayload: ScheduledTaskParamsPanelCsv = await scheduleTaskFn( - jobParams, - req.headers, - context, - req - ); + // FIXME: no scheduleTaskFn for immediate download + const jobDocPayload = await scheduleTaskFn(jobParams, req.headers, context, req); const { content_type: jobOutputContentType, content: jobOutputContent, @@ -91,11 +86,12 @@ export function registerGenerateCsvFromSavedObjectImmediate( return res.ok({ body: jobOutputContent || '', headers: { - 'content-type': jobOutputContentType, + 'content-type': jobOutputContentType ? jobOutputContentType : [], 'accept-ranges': 'none', }, }); } catch (err) { + logger.error(err); return handleError(res, err); } }) diff --git a/x-pack/plugins/reporting/server/routes/generation.test.ts b/x-pack/plugins/reporting/server/routes/generation.test.ts index 7de7c68122125..c73c443d2390b 100644 --- a/x-pack/plugins/reporting/server/routes/generation.test.ts +++ b/x-pack/plugins/reporting/server/routes/generation.test.ts @@ -4,16 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import supertest from 'supertest'; import { UnwrapPromise } from '@kbn/utility-types'; +import { of } from 'rxjs'; +import sinon from 'sinon'; import { setupServer } from 'src/core/server/test_utils'; -import { registerJobGenerationRoutes } from './generation'; -import { createMockReportingCore } from '../test_helpers'; +import supertest from 'supertest'; import { ReportingCore } from '..'; import { ExportTypesRegistry } from '../lib/export_types_registry'; -import { ExportTypeDefinition } from '../types'; -import { LevelLogger } from '../lib'; -import { of } from 'rxjs'; +import { createMockReportingCore } from '../test_helpers'; +import { createMockLevelLogger } from '../test_helpers/create_mock_levellogger'; +import { registerJobGenerationRoutes } from './generation'; type setupServerReturn = UnwrapPromise>; @@ -21,7 +21,8 @@ describe('POST /api/reporting/generate', () => { const reportingSymbol = Symbol('reporting'); let server: setupServerReturn['server']; let httpSetup: setupServerReturn['httpSetup']; - let exportTypesRegistry: ExportTypesRegistry; + let mockExportTypesRegistry: ExportTypesRegistry; + let callClusterStub: any; let core: ReportingCore; const config = { @@ -29,7 +30,7 @@ describe('POST /api/reporting/generate', () => { const key = args.join('.'); switch (key) { case 'queue.indexInterval': - return 10000; + return 'year'; case 'queue.timeout': return 10000; case 'index': @@ -42,56 +43,45 @@ describe('POST /api/reporting/generate', () => { }), kbnConfig: { get: jest.fn() }, }; - const mockLogger = ({ - error: jest.fn(), - debug: jest.fn(), - } as unknown) as jest.Mocked; + const mockLogger = createMockLevelLogger(); beforeEach(async () => { ({ server, httpSetup } = await setupServer(reportingSymbol)); httpSetup.registerRouteHandlerContext(reportingSymbol, 'reporting', () => ({})); - const mockDeps = ({ + + callClusterStub = sinon.stub().resolves({}); + + const mockSetupDeps = ({ elasticsearch: { - legacy: { - client: { callAsInternalUser: jest.fn() }, - }, + legacy: { client: { callAsInternalUser: callClusterStub } }, }, security: { - license: { - isEnabled: () => true, - }, + license: { isEnabled: () => true }, authc: { - getCurrentUser: () => ({ - id: '123', - roles: ['superuser'], - username: 'Tom Riddle', - }), + getCurrentUser: () => ({ id: '123', roles: ['superuser'], username: 'Tom Riddle' }), }, }, router: httpSetup.createRouter(''), - licensing: { - license$: of({ - isActive: true, - isAvailable: true, - type: 'gold', - }), - }, + licensing: { license$: of({ isActive: true, isAvailable: true, type: 'gold' }) }, } as unknown) as any; - core = await createMockReportingCore(config, mockDeps); - exportTypesRegistry = new ExportTypesRegistry(); - exportTypesRegistry.register({ + + core = await createMockReportingCore(config, mockSetupDeps); + + mockExportTypesRegistry = new ExportTypesRegistry(); + mockExportTypesRegistry.register({ id: 'printablePdf', + name: 'not sure why this field exists', jobType: 'printable_pdf', jobContentEncoding: 'base64', jobContentExtension: 'pdf', validLicenses: ['basic', 'gold'], - } as ExportTypeDefinition); - core.getExportTypesRegistry = () => exportTypesRegistry; + scheduleTaskFnFactory: () => () => ({ scheduleParamsTest: { test1: 'yes' } }), + runTaskFnFactory: () => () => ({ runParamsTest: { test2: 'yes' } }), + }); + core.getExportTypesRegistry = () => mockExportTypesRegistry; }); afterEach(async () => { - mockLogger.debug.mockReset(); - mockLogger.error.mockReset(); await server.stop(); }); @@ -147,14 +137,9 @@ describe('POST /api/reporting/generate', () => { ); }); - it('returns 400 if job handler throws an error', async () => { - const errorText = 'you found me'; - core.getEnqueueJob = async () => - jest.fn().mockImplementation(() => ({ - toJSON: () => { - throw new Error(errorText); - }, - })); + it('returns 500 if job handler throws an error', async () => { + // throw an error from enqueueJob + core.getEnqueueJob = jest.fn().mockRejectedValue('Sorry, this tests says no'); registerJobGenerationRoutes(core, mockLogger); @@ -163,9 +148,27 @@ describe('POST /api/reporting/generate', () => { await supertest(httpSetup.server.listener) .post('/api/reporting/generate/printablePdf') .send({ jobParams: `abc` }) - .expect(400) + .expect(500); + }); + + it(`returns 200 if job handler doesn't error`, async () => { + callClusterStub.withArgs('index').resolves({ _id: 'foo', _index: 'foo-index' }); + + registerJobGenerationRoutes(core, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post('/api/reporting/generate/printablePdf') + .send({ jobParams: `abc` }) + .expect(200) .then(({ body }) => { - expect(body.message).toMatchInlineSnapshot(`"${errorText}"`); + expect(body).toMatchObject({ + job: { + id: expect.any(String), + }, + path: expect.any(String), + }); }); }); }); diff --git a/x-pack/plugins/reporting/server/routes/generation.ts b/x-pack/plugins/reporting/server/routes/generation.ts index b4c81e698ce71..017e875931ae2 100644 --- a/x-pack/plugins/reporting/server/routes/generation.ts +++ b/x-pack/plugins/reporting/server/routes/generation.ts @@ -11,7 +11,6 @@ import { ReportingCore } from '../'; import { API_BASE_URL } from '../../common/constants'; import { LevelLogger as Logger } from '../lib'; import { registerGenerateFromJobParams } from './generate_from_jobparams'; -import { registerGenerateCsvFromSavedObject } from './generate_from_savedobject'; import { registerGenerateCsvFromSavedObjectImmediate } from './generate_from_savedobject_immediate'; import { HandlerFunction } from './types'; @@ -43,24 +42,32 @@ export function registerJobGenerationRoutes(reporting: ReportingCore, logger: Lo return res.forbidden({ body: licenseResults.message }); } - const enqueueJob = await reporting.getEnqueueJob(); - const job = await enqueueJob(exportTypeId, jobParams, user, context, req); - - // return the queue's job information - const jobJson = job.toJSON(); - const downloadBaseUrl = getDownloadBaseUrl(reporting); - - return res.ok({ - headers: { - 'content-type': 'application/json', - }, - body: { - path: `${downloadBaseUrl}/${jobJson.id}`, - job: jobJson, - }, - }); + try { + const enqueueJob = await reporting.getEnqueueJob(); + const job = await enqueueJob(exportTypeId, jobParams, user, context, req); + + // return the queue's job information + const jobJson = job.toJSON(); + const downloadBaseUrl = getDownloadBaseUrl(reporting); + + return res.ok({ + headers: { + 'content-type': 'application/json', + }, + body: { + path: `${downloadBaseUrl}/${jobJson.id}`, + job: jobJson, + }, + }); + } catch (err) { + logger.error(err); + throw err; + } }; + /* + * Error should already have been logged by the time we get here + */ function handleError(res: typeof kibanaResponseFactory, err: Error | Boom) { if (err instanceof Boom) { return res.customError({ @@ -87,12 +94,10 @@ export function registerJobGenerationRoutes(reporting: ReportingCore, logger: Lo }); } - return res.badRequest({ - body: err.message, - }); + // unknown error, can't convert to 4xx + throw err; } registerGenerateFromJobParams(reporting, handler, handleError); - registerGenerateCsvFromSavedObject(reporting, handler, handleError); // FIXME: remove this https://github.com/elastic/kibana/issues/62986 registerGenerateCsvFromSavedObjectImmediate(reporting, handleError, logger); } diff --git a/x-pack/plugins/reporting/server/routes/lib/job_response_handler.ts b/x-pack/plugins/reporting/server/routes/lib/job_response_handler.ts index a8492481e6b13..651f1c34fee6c 100644 --- a/x-pack/plugins/reporting/server/routes/lib/job_response_handler.ts +++ b/x-pack/plugins/reporting/server/routes/lib/job_response_handler.ts @@ -46,20 +46,20 @@ export function downloadJobResponseHandlerFactory(reporting: ReportingCore) { }); } - const response = getDocumentPayload(doc); + const payload = getDocumentPayload(doc); - if (!WHITELISTED_JOB_CONTENT_TYPES.includes(response.contentType)) { + if (!payload.contentType || !WHITELISTED_JOB_CONTENT_TYPES.includes(payload.contentType)) { return res.badRequest({ - body: `Unsupported content-type of ${response.contentType} specified by job output`, + body: `Unsupported content-type of ${payload.contentType} specified by job output`, }); } return res.custom({ - body: typeof response.content === 'string' ? Buffer.from(response.content) : response.content, - statusCode: response.statusCode, + body: typeof payload.content === 'string' ? Buffer.from(payload.content) : payload.content, + statusCode: payload.statusCode, headers: { - ...response.headers, - 'content-type': response.contentType, + ...payload.headers, + 'content-type': payload.contentType || '', }, }); }; diff --git a/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts b/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts index 427a6362a7258..95b06aa39f07e 100644 --- a/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts +++ b/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts @@ -22,6 +22,7 @@ import { ReportingInternalSetup, ReportingInternalStart } from '../core'; import { ReportingStartDeps } from '../types'; import { ReportingStore } from '../lib'; import { createMockLevelLogger } from './create_mock_levellogger'; +import { Report } from '../lib/store'; (initializeBrowserDriverFactory as jest.Mock< Promise @@ -47,7 +48,7 @@ const createMockPluginStart = ( const store = new ReportingStore(mockReportingCore, logger); return { browserDriverFactory: startMock.browserDriverFactory, - enqueueJob: startMock.enqueueJob, + enqueueJob: startMock.enqueueJob || jest.fn().mockResolvedValue(new Report({} as any)), esqueue: startMock.esqueue, savedObjects: startMock.savedObjects || { getScopedClient: jest.fn() }, uiSettings: startMock.uiSettings || { asScopedToClient: () => ({ get: jest.fn() }) }, 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/management/pages/policy/view/policy_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx index aa7e867e89d6a..fc120d9782e67 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx @@ -23,6 +23,7 @@ import { EuiConfirmModal, EuiCallOut, EuiButton, + EuiHorizontalRule, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -425,19 +426,19 @@ export const PolicyList = React.memo(() => { /> } - bodyHeader={ - policyItems && - policyItems.length > 0 && ( - + > + {policyItems && policyItems.length > 0 && ( + <> + - ) - } - > + + + )} {useMemo(() => { return ( <> 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 6aafee1d77f47..18c2e4cff16bf 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/endpoint/lib/artifacts/manifest.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.test.ts index d3212eb3faf4d..e1f6bac2620ea 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.test.ts @@ -146,7 +146,12 @@ describe('manifest', () => { }); test('Manifest can be created from list of artifacts', async () => { - const manifest = Manifest.fromArtifacts(artifacts, 'v1', ManifestConstants.INITIAL_VERSION); + const oldManifest = new Manifest( + new Date(), + ManifestConstants.SCHEMA_VERSION, + ManifestConstants.INITIAL_VERSION + ); + const manifest = Manifest.fromArtifacts(artifacts, 'v1', oldManifest); expect( manifest.contains( 'endpoint-exceptionlist-linux-v1-5f16e5e338c53e77cfa945c17c11b175c3967bf109aa87131de41fb93b149735' diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.ts index c0124602ddb81..576ecb08d6923 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.ts @@ -47,11 +47,17 @@ export class Manifest { public static fromArtifacts( artifacts: InternalArtifactSchema[], schemaVersion: string, - version: string + oldManifest: Manifest ): Manifest { - const manifest = new Manifest(new Date(), schemaVersion, version); + const manifest = new Manifest(new Date(), schemaVersion, oldManifest.getVersion()); artifacts.forEach((artifact) => { - manifest.addEntry(artifact); + const id = `${artifact.identifier}-${artifact.decodedSha256}`; + const existingArtifact = oldManifest.getArtifact(id); + if (existingArtifact) { + manifest.addEntry(existingArtifact); + } else { + manifest.addEntry(artifact); + } }); return manifest; } @@ -81,8 +87,8 @@ export class Manifest { return this.entries; } - public getArtifact(artifactId: string): InternalArtifactSchema { - return this.entries[artifactId].getArtifact(); + public getArtifact(artifactId: string): InternalArtifactSchema | undefined { + return this.entries[artifactId]?.getArtifact(); } public diff(manifest: Manifest): ManifestDiff[] { diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts index 9726e28f54186..b9e289cee62af 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts @@ -154,7 +154,7 @@ export class ManifestManager { const newManifest = Manifest.fromArtifacts( artifacts, ManifestConstants.SCHEMA_VERSION, - oldManifest.getVersion() + oldManifest ); // Get diffs @@ -202,6 +202,11 @@ export class ManifestManager { for (const diff of adds) { const artifact = snapshot.manifest.getArtifact(diff.id); + if (artifact === undefined) { + throw new Error( + `Corrupted manifest detected. Diff contained artifact ${diff.id} not in manifest.` + ); + } const compressedArtifact = await compressExceptionList(Buffer.from(artifact.body, 'base64')); artifact.body = compressedArtifact.toString('base64'); artifact.encodedSize = compressedArtifact.byteLength; 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: { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index edc5cd04bef1e..dee92c4fbad58 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -12431,8 +12431,6 @@ "xpack.reporting.exportTypes.common.failedToDecryptReportJobDataErrorMessage": "レポートジョブデータの解読に失敗しました。{encryptionKey}が設定されていることを確認してこのレポートを再生成してください。{err}", "xpack.reporting.exportTypes.common.missingJobHeadersErrorMessage": "ジョブヘッダーがありません", "xpack.reporting.exportTypes.csv_from_savedobject.executeJob.failedToAccessPanel": "ジョブ実行のパネルメタデータにアクセスできませんでした", - "xpack.reporting.exportTypes.csv_from_savedobject.executeJob.failedToDecryptReportJobDataErrorMessage": "レポートジョブデータの解読に失敗しました{encryptionKey} が設定されていることを確認してこのレポートを再生成してください。{err}", - "xpack.reporting.exportTypes.csv_from_savedobject.executeJob.missingJobHeadersErrorMessage": "ジョブヘッダーがありません", "xpack.reporting.exportTypes.csv.executeJob.dateFormateSetting": "Kibana の高度な設定「{dateFormatTimezone}」が「ブラウザー」に設定されていますあいまいさを避けるために日付は UTC 形式に変換されます。", "xpack.reporting.exportTypes.csv.executeJob.failedToDecryptReportJobDataErrorMessage": "レポートジョブデータの解読に失敗しました{encryptionKey} が設定されていることを確認してこのレポートを再生成してください。{err}", "xpack.reporting.exportTypes.csv.executeJob.missingJobHeadersErrorMessage": "ジョブヘッダーがありません", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index c24cac9dae9d2..ad3c699db03c8 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -12437,8 +12437,6 @@ "xpack.reporting.exportTypes.common.failedToDecryptReportJobDataErrorMessage": "无法解密报告作业数据。请确保已设置 {encryptionKey},然后重新生成此报告。{err}", "xpack.reporting.exportTypes.common.missingJobHeadersErrorMessage": "作业标头缺失", "xpack.reporting.exportTypes.csv_from_savedobject.executeJob.failedToAccessPanel": "无法访问用于作业执行的面板元数据", - "xpack.reporting.exportTypes.csv_from_savedobject.executeJob.failedToDecryptReportJobDataErrorMessage": "无法解密报告作业数据。请确保已设置 {encryptionKey},然后重新生成此报告。{err}", - "xpack.reporting.exportTypes.csv_from_savedobject.executeJob.missingJobHeadersErrorMessage": "作业标头缺失", "xpack.reporting.exportTypes.csv.executeJob.dateFormateSetting": "Kibana 高级设置“{dateFormatTimezone}”已设置为“浏览器”。日期将格式化为 UTC 以避免混淆。", "xpack.reporting.exportTypes.csv.executeJob.failedToDecryptReportJobDataErrorMessage": "无法解密报告作业数据。请确保已设置 {encryptionKey},然后重新生成此报告。{err}", "xpack.reporting.exportTypes.csv.executeJob.missingJobHeadersErrorMessage": "作业标头缺失", diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap b/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap index 141f34dd540db..b6ce1eceb62a7 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap @@ -1134,7 +1134,7 @@ exports[`MonitorList component renders the monitor list 1`] = `
- 1898 Yr ago + 5m ago
@@ -1311,7 +1311,7 @@ exports[`MonitorList component renders the monitor list 1`] = `
- 1896 Yr ago + 5m ago
diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/monitor_list.test.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/monitor_list.test.tsx index bfff1abd3e532..1d8a7a771e0c5 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/monitor_list.test.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/monitor_list.test.tsx @@ -16,6 +16,7 @@ import { import { MonitorListComponent, noItemsMessage } from '../monitor_list'; import { renderWithRouter, shallowWithRouter } from '../../../../lib'; import * as redux from 'react-redux'; +import moment from 'moment'; jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => { return { @@ -102,11 +103,25 @@ const testBarSummary: MonitorSummary = { }, }; -// Failing: See https://github.com/elastic/kibana/issues/70386 -describe.skip('MonitorList component', () => { - let result: MonitorSummariesResult; +describe('MonitorList component', () => { let localStorageMock: any; + const getMonitorList = (timestamp?: string): MonitorSummariesResult => { + if (timestamp) { + testBarSummary.state.timestamp = timestamp; + testFooSummary.state.timestamp = timestamp; + } else { + testBarSummary.state.timestamp = '125'; + testFooSummary.state.timestamp = '123'; + } + return { + nextPagePagination: null, + prevPagePagination: null, + summaries: [testFooSummary, testBarSummary], + totalSummaryCount: 2, + }; + }; + beforeEach(() => { const useDispatchSpy = jest.spyOn(redux, 'useDispatch'); useDispatchSpy.mockReturnValue(jest.fn()); @@ -119,20 +134,14 @@ describe.skip('MonitorList component', () => { setItem: jest.fn(), }; - // @ts-expect-error replacing a call to localStorage we use for monitor list size + // @ts-expect-error replacing a call to localStorage we use for monitor list size global.localStorage = localStorageMock; - result = { - nextPagePagination: null, - prevPagePagination: null, - summaries: [testFooSummary, testBarSummary], - totalSummaryCount: 2, - }; }); it('shallow renders the monitor list', () => { const component = shallowWithRouter( @@ -163,7 +172,10 @@ describe.skip('MonitorList component', () => { it('renders the monitor list', () => { const component = renderWithRouter( @@ -175,7 +187,7 @@ describe.skip('MonitorList component', () => { it('renders error list', () => { const component = shallowWithRouter( @@ -187,7 +199,7 @@ describe.skip('MonitorList component', () => { it('renders loading state', () => { const component = shallowWithRouter( diff --git a/x-pack/test/reporting_api_integration/reporting/csv_saved_search.ts b/x-pack/test/reporting_api_integration/reporting/csv_saved_search.ts index 3ec2efcb8f88c..c24e5d325e378 100644 --- a/x-pack/test/reporting_api_integration/reporting/csv_saved_search.ts +++ b/x-pack/test/reporting_api_integration/reporting/csv_saved_search.ts @@ -339,101 +339,5 @@ export default function ({ getService }: FtrProviderContext) { await esArchiver.unload('reporting/ecommerce_kibana'); }); }); - - // FLAKY: https://github.com/elastic/kibana/issues/37471 - describe.skip('Non-Immediate', () => { - it('using queries in job params', async () => { - // load test data that contains a saved search and documents - await esArchiver.load('reporting/scripted_small'); - - const params = { - searchId: 'search:f34bf440-5014-11e9-bce7-4dabcb8bef24', - postPayload: { - timerange: { timezone: 'UTC', min: '1979-01-01T10:00:00Z', max: '1981-01-01T10:00:00Z' }, // prettier-ignore - state: { query: { bool: { filter: [ { bool: { filter: [ { bool: { minimum_should_match: 1, should: [{ query_string: { fields: ['name'], query: 'Fe*' } }] } } ] } } ] } } } // prettier-ignore - }, - isImmediate: false, - }; - const { - status: resStatus, - text: resText, - type: resType, - } = (await generateAPI.getCsvFromSavedSearch( - params.searchId, - params.postPayload, - params.isImmediate - )) as supertest.Response; - - expect(resStatus).to.eql(200); - expect(resType).to.eql('application/json'); - const { - path: jobDownloadPath, - job: { index: jobIndex, jobtype: jobType, created_by: jobCreatedBy, payload: jobPayload }, - } = JSON.parse(resText); - - expect(jobDownloadPath.slice(0, 29)).to.equal('/api/reporting/jobs/download/'); - expect(jobIndex.slice(0, 11)).to.equal('.reporting-'); - expect(jobType).to.be('csv_from_savedobject'); - expect(jobCreatedBy).to.be('elastic'); - - const { - title: payloadTitle, - objects: payloadObjects, - jobParams: payloadParams, - } = jobPayload; - expect(payloadTitle).to.be('EVERYBABY2'); - expect(payloadObjects).to.be(null); // value for non-immediate - expect(payloadParams.savedObjectType).to.be('search'); - expect(payloadParams.savedObjectId).to.be('f34bf440-5014-11e9-bce7-4dabcb8bef24'); - expect(payloadParams.isImmediate).to.be(false); - - const { state: postParamState, timerange: postParamTimerange } = payloadParams.post; - expect(postParamState).to.eql({ - query: { bool: { filter: [ { bool: { filter: [ { bool: { minimum_should_match: 1, should: [{ query_string: { fields: ['name'], query: 'Fe*' } }] } } ] } } ] } } // prettier-ignore - }); - expect(postParamTimerange).to.eql({ - max: '1981-01-01T10:00:00.000Z', - min: '1979-01-01T10:00:00.000Z', - timezone: 'UTC', - }); - - const { - indexPatternSavedObjectId: payloadPanelIndexPatternSavedObjectId, - timerange: payloadPanelTimerange, - } = payloadParams.panel; - expect(payloadPanelIndexPatternSavedObjectId).to.be('89655130-5013-11e9-bce7-4dabcb8bef24'); - expect(payloadPanelTimerange).to.eql({ - timezone: 'UTC', - min: '1979-01-01T10:00:00.000Z', - max: '1981-01-01T10:00:00.000Z', - }); - - expect(payloadParams.visType).to.be('search'); - - // check the resource at jobDownloadPath - const downloadFromPath = async (downloadPath: string) => { - const { status, text, type } = await supertestSvc - .get(downloadPath) - .set('kbn-xsrf', 'xxx'); - return { - status, - text, - type, - }; - }; - - await new Promise((resolve) => { - setTimeout(async () => { - const { status, text, type } = await downloadFromPath(jobDownloadPath); - expect(status).to.eql(200); - expect(type).to.eql('text/csv'); - expect(text).to.eql(CSV_RESULT_SCRIPTED_REQUERY); - resolve(); - }, 5000); // x-pack/test/functional/config settings are inherited, uses 3 seconds for polling interval. - }); - - await esArchiver.unload('reporting/scripted_small'); - }); - }); }); }