From 33fd5cf4579f90337fe6de43bc188d6f9f9866e6 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Thu, 9 Jul 2020 12:22:04 -0400 Subject: [PATCH 01/49] [Example] Embeddable by Reference and Value (#68719) Added an attribute service to embeddable start contract which provides a higher level abstraction for embeddables that can be by reference OR by value. Added an example that uses this service. --- .../common/book_saved_object_attributes.ts | 28 ++++ examples/embeddable_examples/common/index.ts | 1 + examples/embeddable_examples/kibana.json | 2 +- .../public/book/book_component.tsx | 90 +++++++++++++ .../public/book/book_embeddable.tsx | 123 +++++++++++++++++ .../public/book/book_embeddable_factory.tsx | 127 ++++++++++++++++++ .../book/create_edit_book_component.tsx | 88 ++++++++++++ .../public/book/edit_book_action.tsx | 93 +++++++++++++ .../embeddable_examples/public/book/index.ts | 21 +++ .../public/create_sample_data.ts | 19 ++- examples/embeddable_examples/public/index.ts | 2 + examples/embeddable_examples/public/plugin.ts | 40 +++++- .../server/book_saved_object.ts | 40 ++++++ examples/embeddable_examples/server/plugin.ts | 2 + .../public/embeddable_panel_example.tsx | 30 +++++ src/plugins/embeddable/public/index.ts | 1 + .../lib/embeddables/attribute_service.ts | 68 ++++++++++ .../public/lib/embeddables/index.ts | 1 + .../embeddables/saved_object_embeddable.ts | 2 +- src/plugins/embeddable/public/mocks.tsx | 1 + src/plugins/embeddable/public/plugin.tsx | 10 ++ 21 files changed, 781 insertions(+), 8 deletions(-) create mode 100644 examples/embeddable_examples/common/book_saved_object_attributes.ts create mode 100644 examples/embeddable_examples/public/book/book_component.tsx create mode 100644 examples/embeddable_examples/public/book/book_embeddable.tsx create mode 100644 examples/embeddable_examples/public/book/book_embeddable_factory.tsx create mode 100644 examples/embeddable_examples/public/book/create_edit_book_component.tsx create mode 100644 examples/embeddable_examples/public/book/edit_book_action.tsx create mode 100644 examples/embeddable_examples/public/book/index.ts create mode 100644 examples/embeddable_examples/server/book_saved_object.ts create mode 100644 src/plugins/embeddable/public/lib/embeddables/attribute_service.ts 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 00000000000000..62c08b7b81362d --- /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 726420fb9bdc36..55715113a12a23 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 486c6322fad933..8ae04c1f6c6444 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 00000000000000..064e13c131a0a7 --- /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 00000000000000..d49bd3280d97d9 --- /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 00000000000000..f4a32fb498a2d3 --- /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 00000000000000..7e2d3cb9d88ab0 --- /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 00000000000000..222f70e0be60f7 --- /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 00000000000000..46f44926e21525 --- /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 bd5ade18aa91eb..d598c32a182fe7 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 ec007f7c626f07..86f50f2b6e1147 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 d65ca1e8e7e8d1..95f4f5b41e198b 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 00000000000000..f0aca57f7925ff --- /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 d956b834d0d3ca..1308ac9e0fc5e5 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 b2807f9a4c3469..ca9675bb7f5a11 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/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 6960550b59d1c7..fafbdda148de87 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 00000000000000..a33f592350d9a8 --- /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 5bab5ac27f3cc9..06cb6e322acf39 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 6ca1800b16de4b..5f093c55e94e4f 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 efd0ccdc4553d7..48e5483124704a 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 03bb4a47792670..508c82c4247eda 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) => { From 8574a92e7afea6ecdae89db6b499b63634724778 Mon Sep 17 00:00:00 2001 From: Bohdan Tsymbala Date: Thu, 9 Jul 2020 18:35:29 +0200 Subject: [PATCH 02/49] Fixing the consistency of util bar in endpoints and policies pages. (#71133) --- .../management/pages/policy/view/policy_list.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) 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 aa7e867e89d6ac..fc120d9782e674 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 ( <> From a9f82a346934b180cb4a35f35cc146d0ab840019 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Thu, 9 Jul 2020 09:58:45 -0700 Subject: [PATCH 03/49] [Reporting] remove async execution from csv_from_savedobject (#71031) * [Reporting] remove async execution from csv_from_savedobject This simplifies the csv_from_savedobject logic by removing the async hook. This was added as premature optimization in the initial PR that added the Download CSV button to the dashboards. * copy out export type ts changes * remove routes * fix i18n --- .../csv_from_savedobject/index.ts | 4 +- .../server/create_job/index.ts | 13 +-- .../server/execute_job.ts | 69 +++---------- .../csv_from_savedobject/types.d.ts | 4 - .../routes/generate_from_savedobject.ts | 85 ---------------- .../generate_from_savedobject_immediate.ts | 12 +-- .../server/routes/generation.test.ts | 99 ++++++++++--------- .../reporting/server/routes/generation.ts | 47 +++++---- .../server/routes/lib/job_response_handler.ts | 14 +-- .../create_mock_reportingplugin.ts | 3 +- .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - .../reporting/csv_saved_search.ts | 96 ------------------ 13 files changed, 115 insertions(+), 335 deletions(-) delete mode 100644 x-pack/plugins/reporting/server/routes/generate_from_savedobject.ts 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 961a046c846e4d..9a9f445de0b138 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 dafac040176075..da9810b03aff6e 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 26b7a24907f402..912ae0809cf924 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 835b352953dfeb..c182fe49a31f63 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 b8326406743b78..00000000000000 --- 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 7d93a36c85bc8f..97441bba709847 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 7de7c68122125a..c73c443d2390bb 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 b4c81e698ce71f..017e875931ae2c 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 a8492481e6b135..651f1c34fee6c5 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 427a6362a72581..95b06aa39f07e4 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/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index edc5cd04bef1e1..dee92c4fbad583 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 c24cac9dae9d2e..ad3c699db03c82 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/test/reporting_api_integration/reporting/csv_saved_search.ts b/x-pack/test/reporting_api_integration/reporting/csv_saved_search.ts index 3ec2efcb8f88c8..c24e5d325e3784 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'); - }); - }); }); } From 1ed98d5199b6620ae513ae4f41f36925902b6036 Mon Sep 17 00:00:00 2001 From: patrykkopycinski Date: Thu, 9 Jul 2020 19:08:43 +0200 Subject: [PATCH 04/49] [Security Solution] Manage timeline templates user flow (#67837) --- .../security_solution/common/constants.ts | 7 - .../common/types/timeline/index.ts | 13 +- .../cypress/screens/timeline.ts | 5 +- .../cypress/tasks/timeline.ts | 3 - .../components/alerts_table/actions.test.tsx | 4 +- .../components/alerts_table/actions.tsx | 26 +- .../components/alerts_table/helpers.test.ts | 390 ++++++++++++++---- .../components/alerts_table/helpers.ts | 85 +++- .../public/graphql/introspection.json | 37 ++ .../security_solution/public/graphql/types.ts | 11 + .../components/recent_timelines/index.tsx | 2 +- .../recent_timelines/recent_timelines.tsx | 15 +- .../recent_timelines/translations.ts | 9 +- .../components/edit_data_provider/helpers.tsx | 24 +- .../edit_data_provider/index.test.tsx | 28 +- .../components/edit_data_provider/index.tsx | 71 +++- .../components/flyout/button/index.tsx | 3 +- .../components/flyout/header/index.tsx | 14 +- .../edit_timeline_batch_actions.tsx | 18 +- .../components/open_timeline/helpers.ts | 71 +++- .../components/open_timeline/index.tsx | 14 +- .../open_timeline/open_timeline.tsx | 55 ++- .../open_timeline_modal_body.tsx | 14 +- .../open_timeline/search_row/index.test.tsx | 14 +- .../open_timeline/search_row/index.tsx | 24 +- .../timelines_table/actions_columns.tsx | 74 +++- .../timelines_table/common_columns.tsx | 5 +- .../timelines_table/extended_columns.tsx | 26 +- .../open_timeline/timelines_table/index.tsx | 34 +- .../open_timeline/timelines_table/mocks.ts | 1 + .../components/open_timeline/translations.ts | 70 ++++ .../components/open_timeline/types.ts | 8 +- .../open_timeline/use_timeline_status.tsx | 11 +- .../__snapshots__/timeline.test.tsx.snap | 3 + .../data_providers.test.tsx.snap | 3 +- .../__snapshots__/empty.test.tsx.snap | 6 +- .../__snapshots__/provider.test.tsx.snap | 2 + .../__snapshots__/providers.test.tsx.snap | 178 ++++---- .../add_data_provider_popover.tsx | 198 +++++++++ .../timeline/data_providers/data_provider.ts | 11 +- .../data_providers/data_providers.test.tsx | 9 +- .../timeline/data_providers/empty.test.tsx | 4 +- .../timeline/data_providers/empty.tsx | 13 +- .../timeline/data_providers/helpers.tsx | 1 + .../timeline/data_providers/index.tsx | 19 +- .../timeline/data_providers/provider.tsx | 4 +- .../data_providers/provider_badge.tsx | 131 ++++-- .../data_providers/provider_item_actions.tsx | 76 +++- .../data_providers/provider_item_badge.tsx | 60 ++- .../data_providers/providers.test.tsx | 55 ++- .../timeline/data_providers/providers.tsx | 317 ++++++++------ .../timeline/data_providers/translations.ts | 35 ++ .../timelines/components/timeline/events.ts | 11 +- .../header/__snapshots__/index.test.tsx.snap | 3 +- .../components/timeline/header/index.test.tsx | 6 +- .../components/timeline/header/index.tsx | 17 +- .../components/timeline/helpers.test.tsx | 29 ++ .../timelines/components/timeline/helpers.tsx | 10 +- .../components/timeline/index.test.tsx | 2 + .../timelines/components/timeline/index.tsx | 44 +- .../timeline/properties/helpers.tsx | 34 +- .../timeline/properties/index.test.tsx | 1 + .../components/timeline/properties/index.tsx | 11 - .../timeline/properties/properties_left.tsx | 8 +- .../properties/properties_right.test.tsx | 23 +- .../timeline/properties/properties_right.tsx | 62 +-- .../timeline/properties/translations.ts | 7 + .../search_or_filter/search_or_filter.tsx | 109 ++--- .../timeline/search_super_select/index.tsx | 2 +- .../selectable_timeline/index.test.tsx | 4 +- .../timeline/selectable_timeline/index.tsx | 16 +- .../components/timeline/timeline.test.tsx | 9 +- .../components/timeline/timeline.tsx | 28 +- .../components/timeline/translations.ts | 9 +- .../public/timelines/containers/all/index.tsx | 2 + .../containers/one/index.gql_query.ts | 1 + .../timelines/pages/timelines_page.test.tsx | 29 +- .../public/timelines/pages/timelines_page.tsx | 30 +- .../timelines/store/timeline/actions.ts | 8 + .../public/timelines/store/timeline/epic.ts | 2 + .../timeline/epic_local_storage.test.tsx | 5 +- .../timelines/store/timeline/helpers.ts | 95 ++++- .../public/timelines/store/timeline/model.ts | 4 +- .../timelines/store/timeline/reducer.test.ts | 215 ++++++++++ .../timelines/store/timeline/reducer.ts | 13 +- .../server/graphql/timeline/schema.gql.ts | 7 + .../security_solution/server/graphql/types.ts | 16 + .../lib/timeline/pick_saved_timeline.ts | 1 + .../routes/create_timelines_route.test.ts | 14 +- .../routes/import_timelines_route.test.ts | 8 +- .../timeline/routes/import_timelines_route.ts | 4 +- .../routes/update_timelines_route.test.ts | 16 +- .../utils/compare_timelines_status.test.ts | 18 +- .../timeline/routes/utils/failure_cases.ts | 13 +- .../server/lib/timeline/saved_object.ts | 24 +- .../lib/timeline/saved_object_mappings.ts | 6 + 96 files changed, 2418 insertions(+), 869 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 3b5b5958c879fb..7cd5692176ee36 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 d05c44601e1f23..90d254b15e8b38 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 c673cf34b6daeb..14282b84b5ffcf 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 761fd2c1e6a0bd..37ce9094dc5941 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 bd62b79a3c54e6..2fa7cfeedcd155 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 ba392e9904cc46..24f292cf9135bc 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 ad4f5cf8b4aa88..4decddd6b88860 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 11a03b04268911..5025d782e2aa29 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 86ee84f2e8bf48..2b8b07cb6a24b3 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 bf5725c2ddea56..2c8f2e63356e6d 100644 --- a/x-pack/plugins/security_solution/public/graphql/types.ts +++ b/x-pack/plugins/security_solution/public/graphql/types.ts @@ -185,6 +185,8 @@ export interface DataProviderInput { queryMatch?: Maybe; and?: Maybe; + + type?: Maybe; } export interface QueryMatchInput { @@ -342,6 +344,11 @@ export enum TlsFields { _id = '_id', } +export enum DataProviderType { + default = 'default', + template = 'template', +} + export enum TimelineStatus { active = 'active', draft = 'draft', @@ -2030,6 +2037,8 @@ export interface DataProviderResult { queryMatch?: Maybe; + type?: Maybe; + and?: Maybe; } @@ -5523,6 +5532,8 @@ export namespace GetOneTimeline { kqlQuery: Maybe; + type: Maybe; + queryMatch: Maybe; and: Maybe; diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx b/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx index 8f2b3c7495f0d7..4f9784b1f84bf8 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 d91c2be214e8be..ddad72081645bf 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 2160a05cb9da5c..5d01995ac6380c 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 95f3ec3b316493..72386a2b287f11 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 a1392ad8b82707..5896a02b82023f 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 8e34e11e85729b..10f20eeacbcb01 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 15c078e1753553..27fda48b69598f 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 e841718c8119b8..03a6d475b3426e 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 ea63f2b7b0710a..6d332c79f77cdc 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 849143894efe0b..60b009f59c13b0 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 77aa306157c92f..2e6dcb85ad769f 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 6f9178664ccf0a..5b927db3c37a96 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 5b8eb8fd0365c4..aa4bb3f1e04670 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 e0c7ab68f6bf51..eb9ddcce112d32 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 fdba3247afb38f..2c55edb9034b54 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 8811d5452e0396..c21edaa9165880 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 f17f6aebaddf6a..c321caed46f227 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 7baefaa6ab9516..e38f6ad022d78e 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 46a6970720defb..14304b99263acb 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 dac95c302af277..006da47460012c 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 16094c585911b9..d589a9aa33f060 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 d0d12a135e3dca..a227f39494b610 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 00000000000000..8e1c02bad50a3f --- /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 a6fd8a0ceabbe5..7fe0255132bc97 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 3a8c0d88312174..754d7f9c47edfd 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 598d9233cb01df..e1fad47e4204e4 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 691c919029261b..a6e70791d1ec77 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 9dc66a930ccc0d..923ef86c0bbc0b 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 90411f975da0b1..c9e06f89af41c7 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 8fd164eb8a3e2f..2b598c7cf04f03 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 b3682c0d551475..af63957d350758 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 540b1b80259a01..7aa782c05c0dd1 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 1f6fe998a44e95..bc7c313553f1ea 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 9dc0b762244582..b788f70cb2e4a1 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 b5d44cf8544588..c9dd906cee59b1 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 104ff44cb9b7c7..48f1f4e2218d20 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 6c9a9b8b896797..4653880739c6d8 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 b3b39236150eca..f94c30c5a102d9 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 0541dee4b1e52e..93af374b15b564 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 1038ac4b695872..391d367ad3dc35 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 a3fc692c3a8a85..a0087ab638dbf5 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 c88f36a2fb16bb..50a7782012b76b 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 93ccf6992d1f57..5265efc8109a48 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 7b5e9c0c4c949b..452808e51c096b 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 3a28c26a16c9a0..ce99304c676eeb 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 b3567151c74b35..6de40725f461c8 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 4673ba662b2e98..a3cd8802c36bca 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 a36e841f3f8714..3f02772b46bb39 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 8a1bf0a842cb0b..70257c97a6887e 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 b549fdab8ea4a4..825d4fe3b29b11 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 0ff4c0a70fff27..6bea5a7b7635ec 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 dacaf325130d70..ae8bf530907893 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 b58505546c3417..360737ce41d2df 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 b930325c3d35da..ee48f97164b863 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 ebd27f9bffa5e1..f8c38b3527d7af 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 17cc0f64de0392..4ecabeef16dffb 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 47e80b005fb995..24beed0801aa6c 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 1bd5874394df3b..2e59dbb72233ff 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 089a928403b0b1..56aff3ec8aaacd 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 94acb9d92075b7..605700cb71a2a4 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 388869194085c8..7d65181db65fd8 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 33770aacde6bba..a347d3e41e206b 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 57895fea8f8ff8..a78fbc41ac4307 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 6e7a36079a0c34..b8bdb4f2ad7f0d 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 4072b4ac2f78b3..6bb546c16b6170 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts @@ -39,6 +39,7 @@ import { updateDataProviderEnabled, updateDataProviderExcluded, updateDataProviderKqlQuery, + updateDataProviderType, updateDescription, updateEventType, updateHighlightedDropAndProviderId, @@ -88,6 +89,7 @@ import { updateTimelineProviderExcluded, updateTimelineProviderProperties, updateTimelineProviderKqlQuery, + updateTimelineProviderType, updateTimelineProviders, updateTimelineRange, updateTimelineShowTimeline, @@ -427,7 +429,16 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) }), }) ) - + .case(updateDataProviderType, (state, { id, type, providerId, andProviderId }) => ({ + ...state, + timelineById: updateTimelineProviderType({ + id, + type, + providerId, + timelineById: state.timelineById, + andProviderId, + }), + })) .case(updateDataProviderKqlQuery, (state, { id, kqlQuery, providerId }) => ({ ...state, timelineById: updateTimelineProviderKqlQuery({ diff --git a/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts b/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts index a9d07389797dba..e46d3be44dbd1e 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 a702b1a72f0a9a..52bb4a9862160b 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 68e7f8d5e6fe19..eb8f6f5022985d 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 84a18cb1573dd8..0286ef558810e1 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 15fb8f3411cfab..248bf358064c02 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 0f4e8f3204e2bb..56e4e81b4214b6 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 3cedb925649a27..17e6e8a84ef224 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 a6d379e534bc28..6e3e3a420963f1 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 5e7a73ca18d0ed..d41e8fc1909836 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 ec90fc6d8e0710..f4dbd2db3329c6 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 51bff033b87911..22b98930f31810 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: { From 14752eafa75fa64087d42ed1939c200dfd5ef02c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Thu, 9 Jul 2020 18:13:07 +0100 Subject: [PATCH 05/49] [Observability] Fetch news feed (#71212) * adding new feed * adding log when an error happens * fixing translations Co-authored-by: Elastic Machine --- .../public/components/app/news/index.test.tsx | 16 --- .../app/news/mock/news.mock.data.ts | 34 ------- .../app/{news => news_feed}/index.scss | 0 .../components/app/news_feed/index.test.tsx | 68 +++++++++++++ .../app/{news => news_feed}/index.tsx | 80 ++++++++------- .../public/pages/overview/index.tsx | 10 ++ .../pages/overview/mock/news_feed.mock.ts | 72 ++++++++++++++ .../pages/overview/overview.stories.tsx | 48 +++++++++ .../public/services/get_news_feed.test.ts | 98 +++++++++++++++++++ .../public/services/get_news_feed.ts | 27 +++++ .../services/get_observability_alerts.ts | 2 + 11 files changed, 367 insertions(+), 88 deletions(-) delete mode 100644 x-pack/plugins/observability/public/components/app/news/index.test.tsx delete mode 100644 x-pack/plugins/observability/public/components/app/news/mock/news.mock.data.ts rename x-pack/plugins/observability/public/components/app/{news => news_feed}/index.scss (100%) create mode 100644 x-pack/plugins/observability/public/components/app/news_feed/index.test.tsx rename x-pack/plugins/observability/public/components/app/{news => news_feed}/index.tsx (53%) create mode 100644 x-pack/plugins/observability/public/pages/overview/mock/news_feed.mock.ts create mode 100644 x-pack/plugins/observability/public/services/get_news_feed.test.ts create mode 100644 x-pack/plugins/observability/public/services/get_news_feed.ts 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 cae6b4aec0c625..00000000000000 --- 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 5c623bb9134eb0..00000000000000 --- 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 00000000000000..c71130b57c33fd --- /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 41a4074f479765..2fbd6659bcb5aa 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/overview/index.tsx b/x-pack/plugins/observability/public/pages/overview/index.tsx index 9caac7f9d86f4c..3674e69ab57023 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 00000000000000..b23d095e2775ba --- /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 b88614b22e81ae..896cad7b72ecde 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 00000000000000..49eb2da803ab6a --- /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 00000000000000..3a6e60fa741883 --- /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 1bbabbad2834a0..49855a30c16f6d 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 []; } } From 8a1d7d7e407f4e62c52ca597911b8024a4006d19 Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Thu, 9 Jul 2020 13:14:42 -0400 Subject: [PATCH 06/49] [Console] Fix example in help flyout (#71188) --- .../application/components/editor_example.tsx | 13 +++++++++++-- .../public/application/constants/help_example.txt | 8 -------- 2 files changed, 11 insertions(+), 10 deletions(-) delete mode 100644 src/plugins/console/public/application/constants/help_example.txt diff --git a/src/plugins/console/public/application/components/editor_example.tsx b/src/plugins/console/public/application/components/editor_example.tsx index e9e252e4ebb17c..72a1056b1a866e 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 fd37c413670337..00000000000000 --- 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 From 4b2e5bd1d7d0395bf508cfe81d4329ab9c2a58f9 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Thu, 9 Jul 2020 13:23:50 -0400 Subject: [PATCH 07/49] get rid of console err in management. disable links in menu if needed (#71118) --- .../components/navigation_menu/main_tabs.tsx | 2 +- .../components/analytics_list/use_actions.tsx | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) 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 859d649416267e..3a4875fa243fda 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 e75d9381169911..cb46a88fa3b21c 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 && } From 0d3f7a18ae3f782229de0a7fcbf371be6b790cb5 Mon Sep 17 00:00:00 2001 From: Madison Caldwell Date: Thu, 9 Jul 2020 14:00:41 -0400 Subject: [PATCH 08/49] Preserve compressed artifacts when updating manifest (#71196) Co-authored-by: Elastic Machine --- .../endpoint/lib/artifacts/manifest.test.ts | 7 ++++++- .../server/endpoint/lib/artifacts/manifest.ts | 16 +++++++++++----- .../manifest_manager/manifest_manager.ts | 7 ++++++- 3 files changed, 23 insertions(+), 7 deletions(-) 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 d3212eb3faf4dd..e1f6bac2620ea4 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 c0124602ddb811..576ecb08d69232 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 9726e28f541868..b9e289cee62af1 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; From e54729caf2fcf6a1f917b27b195b049f62234d91 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Thu, 9 Jul 2020 14:32:39 -0400 Subject: [PATCH 09/49] Dashboard add or update panel (#71130) Added a standard method for adding or replacing a panel on a dashboard. --- .../application/dashboard_app_controller.tsx | 5 +- .../embeddable/dashboard_container.tsx | 68 +++++++++++++------ 2 files changed, 50 insertions(+), 23 deletions(-) diff --git a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx index a321bc7959c5c8..8138e1c7f4dfdb 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 f1ecd0f221926b..ff74580ba256be 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) { From 4b4796ddbbc3a6aa02d1ed9967ca9f8eec28467b Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Thu, 9 Jul 2020 12:33:37 -0600 Subject: [PATCH 10/49] [SIEM][Detection Engine][Lists] Adds "wait_for" to all the create, update, patch, delete endpoints ## Summary * Adds "wait_for" to all the create, update, patch, and delete endpoints * Ran some quick tests against import and the performance still looks acceptable * Updates the unit tests to reflect the addition ### Checklist - [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios --- .../lists/server/services/items/create_list_item.test.ts | 1 + x-pack/plugins/lists/server/services/items/create_list_item.ts | 1 + .../lists/server/services/items/create_list_items_bulk.test.ts | 2 ++ .../lists/server/services/items/create_list_items_bulk.ts | 1 + .../lists/server/services/items/delete_list_item.test.ts | 1 + x-pack/plugins/lists/server/services/items/delete_list_item.ts | 1 + .../server/services/items/delete_list_item_by_value.test.ts | 1 + .../lists/server/services/items/delete_list_item_by_value.ts | 1 + x-pack/plugins/lists/server/services/items/update_list_item.ts | 1 + x-pack/plugins/lists/server/services/lists/create_list.test.ts | 1 + x-pack/plugins/lists/server/services/lists/create_list.ts | 1 + x-pack/plugins/lists/server/services/lists/delete_list.test.ts | 2 ++ x-pack/plugins/lists/server/services/lists/delete_list.ts | 2 ++ x-pack/plugins/lists/server/services/lists/update_list.ts | 1 + 14 files changed, 17 insertions(+) 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 7fbdc900fe2a4d..76bd47d217107e 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 333f34946828a8..aa17fc00b25c66 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 4ab1bfb856846c..b2cc0da669e42d 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 463b9735b25784..91e9587aa676a6 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 ea338d9dd37917..b14bddb1268f86 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 b006aed6f6dde3..baeced4b09995c 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 bf1608334ef24b..f658a51730d97f 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 3551cb75dc5bcb..880402fca1bfa5 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 24cd11cbb65e4d..eb20f1cfe3b305 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 43af08bcaf7ffd..e328df710ebe10 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 3925fa5f0170c5..3d396cf4d5af9f 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 b9f1ec4d400be7..029b6226a7375d 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 64359b72732744..152048ca9cac6f 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 c7cc30aaae908c..f84ca787eaa7cd 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, From 733f33880055b1e3ce3926650459d787aebf8c27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yulia=20=C4=8Cech?= <6585477+yuliacech@users.noreply.github.com> Date: Thu, 9 Jul 2020 20:37:05 +0200 Subject: [PATCH 11/49] [ILM] Change "wait for snapshot" policy text field to EuiCombobox (#70627) * [ILM] Change "Wait for snapshot policy" text field to a dropdown in Delete phase * [ILM] Change "wait for snapshot" field to a EuiCombobox and update jest tests * [ILM] Update jest tests to check callouts * [ILM] Implement PR review suggestions * Update x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/snapshot_policies/snapshot_policies.tsx Co-authored-by: Adam Locke * Update x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/snapshot_policies/snapshot_policies.tsx Co-authored-by: Adam Locke * Update x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/snapshot_policies/snapshot_policies.tsx Co-authored-by: Adam Locke * [ILM] Fix copy * [ILM] Fix copy * [ILM] Fix build error * [ILM] Delete periods in callout titles Co-authored-by: Elastic Machine Co-authored-by: Adam Locke --- .../edit_policy/constants.ts | 4 +- .../edit_policy/edit_policy.helpers.tsx | 28 +++- .../edit_policy/edit_policy.test.ts | 56 ++++++- .../helpers/http_requests.ts | 14 +- .../client_integration/helpers/index.ts | 7 +- .../components/delete_phase/delete_phase.js | 14 +- .../components/snapshot_policies/index.ts | 7 + .../snapshot_policies/snapshot_policies.tsx | 157 ++++++++++++++++++ .../application/services/{api.js => api.ts} | 40 +++-- .../public/application/services/http.ts | 12 +- .../routes/api/snapshot_policies/index.ts | 12 ++ .../snapshot_policies/register_fetch_route.ts | 42 +++++ .../server/routes/index.ts | 2 + 13 files changed, 355 insertions(+), 40 deletions(-) create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/snapshot_policies/index.ts create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/snapshot_policies/snapshot_policies.tsx rename x-pack/plugins/index_lifecycle_management/public/application/services/{api.js => api.ts} (56%) create mode 100644 x-pack/plugins/index_lifecycle_management/server/routes/api/snapshot_policies/index.ts create mode 100644 x-pack/plugins/index_lifecycle_management/server/routes/api/snapshot_policies/register_fetch_route.ts 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 225432375dc757..e5037a6477aca4 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 d6c955e0c08133..cba496ee0f2125 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 8753f01376d42e..06829e6ef6f1e3 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 f41742fc104ff5..04f58f93939ca3 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 3cff2e3ab050f5..7b227f822fa97a 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 299bf28778ab43..34d1c0f8de2166 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 00000000000000..f33ce81eb6157b --- /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 00000000000000..76eae0f906d0c2 --- /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 6b46d6e6ea7356..065fb3bcebca7b 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 47e96ea28bb8cb..c54ee15fd69bf6 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 00000000000000..19fbc45010ea2e --- /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 00000000000000..7a52648e29ee8b --- /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 35996721854c63..f7390debbe1773 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); } From 0f09f6b140d271513d691f28eb1e29f6beac2590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Thu, 9 Jul 2020 19:41:36 +0100 Subject: [PATCH 12/49] [Observability] illustration for landing page (#71217) * changin illustration * renaming files Co-authored-by: Elastic Machine --- .../public/assets/illustration_dark.svg | 1 + .../public/assets/illustration_light.svg | 1 + .../public/assets/observability_overview.png | Bin 98273 -> 0 bytes .../public/pages/landing/index.tsx | 4 +++- 4 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugins/observability/public/assets/illustration_dark.svg create mode 100644 x-pack/plugins/observability/public/assets/illustration_light.svg delete mode 100644 x-pack/plugins/observability/public/assets/observability_overview.png 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 00000000000000..44815a7455144d --- /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 00000000000000..1690c68fd595ab --- /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 70be08af9745ad4d7ab427d9b3d4c1d2d2f1e75a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 98273 zcmc$`i93}4_XkY&u`eT$Jz<8j6Ghe}S!V`gCybr!L}9E&T1Jw+Y_k|U*>|!RV$EduTDl*_MHB& zJrx=Gabw)^%J_ZD+eKb!4a%4Trw@J!zql+SmPM1QStM+7+nsrdye;nCQ{6* zJvKo1lbQ5nId8ux_XyFKd*FUccunw~?0zzzh31orp_^X@OX!}wl(0-q$dk0-pd)AG zEkIKnlLX>*Yy-@k5zaat9XzaOy*>DNJiZqyw0rU*0pBaUgD*)`Y~DI{*U-|^`n0=y zeFFI5!L(uT_2zF;EUb(-tmFUhfg@1?7=G2 z_+|Hvay>lXo6knNbig+e!$60#KGfg}tnTW+3E%Ao0sEWxK@$Tf5i83jUu?j)+!b47 z(C2$4_R~BP*udBR#U3Z_Xv9YaY{kDVWJ80`4WYeDm%i$btiQC3>p|N29~Phfdtc5( zhTTy{)zas=>161H3j$kSVevW%r#M4QIKQdtKWq7{dpn=h|90eM=H;h1AA1B&-Qw^j z$o8BsVj}BUA3G~+(glVfe*2$~43cO4uF5pH#j?=9xl{DdprV0MuR>?X5T5r_^Pe(& zMu=pk=D%CN=1si!C!LKk9_|-G_AVmHtXF7>@kM{Fu2;jah5??zrA#U3#rS93Xrqdt zxrliw&>OXgtp=;JUN&gi`S_{z*@4IFWo4fRRqOM4-t=5c)g{rDnOu7D$uVgKo;c@o zz{<0oys$318E$z81EVE+H9U+Fqy71?bN#ruC`&{B+CXuS(JP|bPBeVE`^5l4{`@pSA~abpO3ug?7yATni4D|vkM&S z3V87)mO%w(8>8MyTE!^xv_U88${@Ht@9K#;W2OL6V4mD8|BmzBI3u?(_=oC>GpC@$ z!KM4&SI1a1htU|yce!w%jU@LMkC1+!j**i1ub(Lql7;}|(3*`Jrx(as3bJwGF(s}L(0E^8( z8rRWzuo%y-cVEG#z+``l3O>AKqpT%vOAQ+)Nl(+T{Z5<@spa|BbeI zSVk%N2}A_;2D;8z3bCAiXq4)zgRr;1-Qc`EBnI3(FJi&g2DspEOfOd7fXO4?67*TM zD30=MQ?kO}zGRpSHhKn?(e}=h!BZt_{zY|E3S!ubN|`ZYA2f^G1ni zDQ7cUhx@qf&hHYVu=$xi_M0BU=l*P!`M(8w@Ib2|`Kf;P!zjr8E3bmSymq}&x?bD! z=UGh>`rh-6Oqy5{jk^IEiLR10bxc8H$nk%V_nACj87>QCo$~nL((>cIwIo6$*N+IU ziP#sZ_AH!Nd7bxKr7(oNXK^sgVg-%q%X6)N&X?V{1z2{m$kf!-j9WivBE#Nb9XRBq znMu13p7wtdyd>^ehmdvo&LU?G{gHk-uafq_!jBIZSljJB?e|a@Ni#&sEikr^h5NSS z^4j{Lx4w_7AepKbt~D^>S|9|^yNa*NGG!N5oJ70w8=Az=A2_N4cug_RDaHqGo|F`+ z<_5a&Yi1BCk%{&wmk0bClvg&W1=H(!>j|YxrNTAxK{j6&G@co7_e=C~TB|(@{xTU{ z&~AOhm@z=5^Qbq9wm99AZv3kTe}-S(Zt76-C#f%t!jDejZy=?kZfJYWMc@CzYTlf~ z6YbZx32j-WPedWZ2_WCRsCNkafe68Y2(b996~%pT;T%^{BUd#!*YMW;=#=lwp9{$E zx@h-CJaLdaaSJlLB-cocLshjp+qbe^DDDcF?%ON{4>V)i`@rLilVFgi^&60#-L zA#>QcOEiik-B+>HR(y6_?6brQB`0ti(K;6|;C@aUYE{2AxU9Aqn*p3C-CtvMEI?Fi z1x^z8lfHXq7dxb~Zlgpv%}Rvwm?jF$obst0wsulqReOYiEdY;1rBfYrZrq&@QMX@w za>+lg(YY(Fpt6BB6V+@V5P+=?WZaN+yaOr^UAm9h0L~6?i_i5tS{$aW**5+2W$Il|fQ`x{{i4-xsR%0?B)Ea%HLc z^!g)BU%EQ8d@QL*PXZ~;V{KzT@CdP2JhY?jsQeyYjoEDfi<{lxY_Gu~)E{UMpngHH zUa#6)0-h@ijt!WVC~b|OBZ0hgRJO;3Epe+meP%v3d2*dVxS^8r#$!5kGO%fEYC^i^ zz+%?>t0hPM{3ExUlG3Bg27h%w*Kv&byN4As5u2|rRUNOajH8)0S8RjZCMh`Is{W)h z)KUKXkoMzmg~zkP3>6TYJw()p^0&7N_as^#M#UE0FfYO`c)b4{1;IwcY~(e!6Ij-c z%u=}`fT*znI5A`9#&Z7?kNa;{G8_9ee&=Tl_sXa(Jt_-6_39R8wV~Y*W zw5@qjrCjuuk)>aj$Tt(6(qH(Mma{XI>d-CQb=xXF9A;|TC6j(!0>zxzJXj=k?|U`8;jCoZP^v*FuYGdzp-Fmtz*eFv;#@$u6|QYtv~uO@P_$IEcB z*?ep-klc6?wrpt4CX{EO&-*;f6O)Z)b|k)TY;k*35D^htixBm&i7Z8~Z`@wbB3MKKj6n{#)w&;;eB4T=b?+U`!MaK(Fn?VT z0J>RN12n4^t60+u4#Jhc#US4)X4*lG$k9^tU<*x%2UQ!AAK}+7a3JMD&J#g!wk24H znC?e&j#n-H=(~S8+a3x50vzpW;FVP&>+x)qDx+t#d|&AI)5Ak~L7~;u^|W!Fw0&qw zIeVH_G8@Z_o!DoIrC?h3&N+mgIt*h5bLV1=d&X|01Gmks@^`^CPCm@v8|Xp6D#aZ5 z&ZD$E;F?E8ikd*~yjs%&P-1-1+ORT8ZbMhNzgLM|FTy`Cq8%Y12Ztk>wW-W$`SUI? z=`|rLh}9*;9(6ke;&quY)%h{hIbVS*_#-`uke3T!);%rh551u1X=(f|#Rkdf0XfI5 zM?_?Cjpj`5R9-KXmkn#YBbe5)8f`CiyqeQ~Y)k;dV`B7FtKS1h=--&Ah}W!D#;w9* z##-xF46Rs%@@#ZnmWKPWx*?Xa>Y;dP2`on#zfGh?v36Fs`eu7EJPd_%!y>9JVV+J` zyJG;`T{Y1tD-Yc~3pdaw)CYWf_tC|LQ;iFEcn`O7I(|diNxkk<%F8xB{0=0EdwL2FR;o<`_d}{?w##1kAfVo*dDSN%d4JG&6>TkcUHQ&0 zZh=i8(JHtlV*O!qr_!%djKXgEVaY-m%yu?nQC3516iS=>`*jVQAhuYl<5s5YR-O4< zmN+y$&%74`^5r^8n1Q~u9G`JXIXLq@QuChsJFm15E?SMJHDem>(lb}5XqkDEft_XK zsb*md__6ba6tg_yKVZWFtYEpmsAP>+;+job42FgkwIDa0YMcUcCE<8wbd{bJO>1oD zT>8uIo>qx%g3KY`7`z_Ob8#t4N&%pE@?&e(fSyUX6-sqL!4qN2YV4i=cUAa4j{n<} zZ3zBW=~VWl(Fq6Q1SYw^<*P%`qP0r!sr^pB01e}F4i1wg>2SqH&j6{>D&VjKdZd0I zryC&a!%j!@fE8zpg5Z^v0@~;+e4e@Cfq;fbQ$gF!Fc>=5pR3~x5~wORd*^3RR;H)^ zsyvr;5!H3F8w{Rkdnu(X(j#D<`A&7Ntfolb*F5mtcnRDueZ$P-N33qd7h+Sn5$E@T zcDGc%rrVudKJ@lD4DI`65#ya_13E;)i0g5ViL3Mqr2PP}yBALZ*BvJ}okbtvJw4V3 z?1VclPLF499GmI$s0-J@&1x_BVzQg;9zlyMRubJ#0XMQAR-GV_uc^HIJpD<#@;Y#n zByIpq@|NfOl5{QCg9(3hwc0=79hmEWRU)fY*>Sv>@}A}UTT1qA7xMJV&itD4rf4TM zlrAF)z>W6RZ*y_Wn|$etFh`2(@oSj~eF96bXT7B*38Jj3HM z^UNBl(l9Zp?UaMvAe@g36#P@ML`!Mr;m86xC#0Z`q*|oa_{P6I1(pRQeyptjON2% zbod{zmr3IPg%E)CTF!ycok>ToMb##|gqbI6sbkK@>Ce*b(<-40#!YX4+x*L^8ROP#$3bb#l{euTnd{~JD8btFkDg*}P^of3@-c%m z3Opj0!jkrtBsQEyZ*7*?64mSc$L>#mM=gM*l9bPvwZQ2M4%pu7`HbG2sS9Wk_KLYO znUGOS49jjga&nSQJTOQgfEWY+z>pWilIQGQwgH{YgOnPDldu7m?8aLQFa-c3X}wPd z45kK-{WL{?B@aE4w_+2SHPyU|t@~ERe^pD_A*bp_4jB}ilma&Y6f=Hpb#D#62d?D) zK6r4R_$xj1UA3D@t{|yKLfh~%SWtQn@>F~K&GB^8FEwq&gA5hB`1*KL2pEknz!=I zcpZ`JDX&%0=ZOq6pdo3mmIgoW(9}(*nl)C9oM1+e1o0RMb`Q&sl|%?V_>rP-3^qRr z&ek4;Jw;`n2SqRt65``uqCBSDd9>gdY*t$tkHIX*WUUQz*^cR;lj)p#nUBrGDeCAp zY&t#?VW(|YE$y$%*KhtbT@h}xHv1PU;#Po!WMBL@&rAP~Lm2?~8)K{0(eSnL1|lm; z+znN~d6s@>SUw;xqNzYJB3mldZEMAwL_XM}5gc26eRd+OIabhrIHNn}Hce~phT2#! zLI!s*^}?$x^t%B+Ul8o6keSdGU?sc1qJaaK_z5_0M-=KDZ8-nxUzML6=X`xiz-`wE z18we=0uDxXd_!vNZIDvx8x-`z`}uJ**M%`N4hQ`mY5JMU%hbvP3zwbc?dSh=k$5x} z&2OsRx)iFsY`gcZOlOuFA*7Xty3>D7|8NH~lsa@xHU|$L&Obud_1N@vUN&<8QGHpMRFnp`@eHT3ytn$^v2CZ z@f*R>U*G5Gxj*W1S9z8TQ>a*xtK4Q(7H|VYu88}mDMe^qHRPrDjAMxI4*WY;^J#VG zor#sjn-A{tXCSc4$uPE;9_k|lkOO(for0Ud2=2aq=Yjt)WwVR8I#Vm?aNIuGXK5o3 zZo?ISn>_alL4 ze{TY=arG9=n9%t1!}6&WAA?`E>SS%5fV2I=Z)cj4k}or7-I31gnOopE!9{10I*`O> zuBKuev?ay2e^9SneW=;Q?FHBz0PRBD@c&Ls4?fiMiORdZ5f*b-8vXRZ>Wp?(g7L&= z@J;HvI4XuJE=qHh;4{z{lf#A%()Z%`o-t-B@1{31pqGu3IH)vdyPfOnIPZV$us_9^ zB$>Y!z&KHzkN6ssFF=Ut38D4UJTYFNJ9_Wt1omfwzx{M0wap)>hydX6;MNplRoZI{ z?b|IZ3faYo%j+OCNJBz%pPHfALxM zkOX81>!QpVC@)I|SndDUH)+(*_j}GggLdMBBVqZ)vvgpDc@>LiT<;hmG?--d(TS-k zPh8*pSRhT3y6C`EL`YZD-Fha9W`>`^!#h9N-P+5lsa&){J%4fvqiE?B=20zhm;KG5 znbF-p`trbYZ0ybn_}5xRIS|ian!fC2fXKO;tQ0rd)i-r|SPi!gms%n&zv*3Xe57wC z@C-RXL`=OPaC(G}-GB3ax#ZJ+b4tA*U;lPsiRg-3!(gX8xI z7i0(Kv$p_8nf`|-rRb^L3nu`4$L*y0P5fr!QCx+aqQted;Q@tw9!muccJvf;@O+Ya zD{emhCg*eu)*{fsW5^ZbaI+1H0xNS#r(2_YaR~TLNW;`n7wMft6)J!}tZ@-^ptyPi zU`%@jv<6|{1cCsAFwQt2rBs?W?0m`YZ+seDUg-re-i5Eqam2hi2=-&yhAGe4Lw52d;j>DhkFK`kVNr`jv zE(%kXnx?V8=xlEfZ}r#hgH=UkiEpLobz_yG_)Z)$v70OiWf2K}#n~q8S13HBY4>pV zN#w}>9$b{=9V2hab0gXfue)5!S(#kSw|E6-bC581OyUD4@6s+HC}_!Hf#~((FX_wp z5b5`C)AR|7>^e)8`K9Z&AFt9B5^jY3>Pr{AxK(%3;er+`mZ}FyMiy0S45PO}3mk2d z8Oe?eZbSD+Eopowf0}-VGS%h6d+Reev-O_$fN7z>xRsu>Kqc&YD$N&4YyrRKc_{+O zGJWUmmuQ0%YsQ3dbyEF}Bs{)W_qmg%;n&Iat<`At&ywi#ql}U(qkfC(kd!2FGThp` zo1h*{QrmRA<^FExH_og?Qc}`7lO3R!htYaaxCr?27e4&(RPAzjbH&m?Zz6~|<5|#X zVS&0eN+B6Grv7bdQ_rb=<#aUhS8+>}TgQpIk6h2fbnQtILT%!qM!{n50`juuKzt%{ z^18wHOt55Dm^`s%!qAu;G3u59c-i)Iv~tnH%gL~O^uwdENkZl;)|hS^*=L1lv4s8t zBRU+50UuaA17JWbyjd&ty`4F)d29DS12KhrSDXHZ)c3c-Rq}Thz%m%FDi#e~qEz^k zR!!VCh!72MRXz5C-xIJf6Hm177*^RUh1?ak=5!f*2tWYp$nyugR$sZ+B2**ggvJh` zQ2N=llrZ`0589+5y=^g<$+5B$=TgIp-&-rjRst)3@c7#@kG7{gxBlG(gUcII!U%P z9@XLM?(m5wM$B~-5=C3WqALEe_-HsVXpZ#hhi9#1TxyME|43p3p|wULhR}eJWOA+z zgpxROqhIjqlTBccK)bj))-OQS<~+=34DqO#_zXSA6@h7KPEHfu1TW_cZWhh;DU%B$ zPHX*jSCv&gns4RPM+F4#8R(wJMRNrw2nr6%UhN(&MP8?NMP( z#f$mN>$GdgTfa?)Ipb5@SA+2g9*@|}vC`FiCBp5ZG|UeR`?rq})%ndvAz*NDFd#u+DN5hc@j~zpNQljD|{<$*^=|l2pl&r53zzttlWyZj>T4Bb=PP^>+X!ZB4xDYTWg zkP8}j(Io^ra7k%3*5s7m_;dX9;1zq>#*JH>ew|G8Ta#~P3lA7eIPm&dF0x|OtaQXWz*QvW&+6aJ$oR;&(R(~7%!rhHuvcKI7&$9z=f${M_J9`!mVGi;-J1um3X|(FW}z1$eFc9#0d{xWo(f1iJ*aPK z$%*N7P423!(&(QiXM-wwErmpy+X~jvR}1*D^T85A`nq>2YZ-&%!zQ+O^?XHBWifWm z7~12{iws@92eT6m25mh_DHQ|@w41cc3u0Ki(isFcDtEMAq^;MKADeJR2et*pU3OChiY zf1wQ2&nYUj<-bY+$nc6xgN9GlX_ut|Q}ZQmL>N5wPUb=ApW6F>8SHvgsy~DC?_XJ& z8<(vC%D8*{|I0dq;k!6gsVV7Zjox1$5RN_>JnX$f1+T?BDcJajk$5Pd->^U__E#(3 z{>Z;*BgkGr(nFsVWw^oz$^*jl3&C~-gs>JB;--V3;mnGyx~J&PB2t`Pyh;RTUfW>i zfak}(DIQ6j#4lBw#)l-v2k)ty4KHWENM{f9R@n;6GfOSW>Rz3`^rG-%8ndyCFEuA? zOu3e$`xWDi$yS!$eMnJMfBN{5V!&ng?8a(!2ET4{&p2d{(0kerDN`t|Uj_ch?SgR` z<6WYI%zH5gjf|ovgjo`aiJs&J)DO94nn-cHhna+p;gMex-zr0G&W8x&n%V+O)-x`U z%EAWGS#gl2cW-RH>$SPm#|HY$glKBs>$#^Pul@A7X8UpfWx<-^&$?5s`{Qp;-F~jE zpyS!%&K>UCf^h9vw6kCJMPt0vj*V0d2nuubv^hXp89Bua)7g*0Oat0e*=@`Nza^rJ z`g;vS$j3WauWy@_T2mtPTS)_2F$})E^Cd8$DcZ1GJ>kp!#XY;F(O;h*S`yki*E?!E z)NzMq#^f^s0t{7cQ!8w$9lY=;do>zbB9lrD2xD%KK?D=x+=}Wg?^*_~Ty`UQO5@p* z`TN8FKIb1e*?qH&-Zw{ve#83#i5Bcz1B>xuX$(L$i6uzs2k)d+bk`uVec^Mr594*c zdU2IlyE|3~$O~m7(Qx{AH09swbU>dpdOUxtu1^@6_pq#xZ|V^`Q~YmUT`vmafe^nq zE=2NU0ls#&c|Z!)toeg&Y(HbArkBrE%77=p*Ln|oRvAb?Ze9E$P`3tJe=KK(he^C~+` zPxWtqhLM={dK9q4{}pA0QP>`^4!Nabyvm4-?W5nCVu6F^imM89aFyTH_G#{!vgx zBbfYwIdmZc=hwxAE!h_A7#nD$_pS^GKW;ETTDjzqyVUdDoFPxJ6>#^!R*(7w^lSK1 z;8Jqei@s=(VcbBaKDS^_RTSiD(rGP%#pkJ9@7sR!Y8**+LONSXl_w;k24cyS2WNTB z;{!&REZd43w;B*3hoMwkBc%{^*jj*!UFS`FDcMIj#sM$TFx8Nt_`x5QC78OK8wcoK z;$tcQ=U3bXR5QOe1#DZuH*ex0s<{VEcXFyuQ-j3f)?2>)RjpysiDBS!n{{h~WmKl` zjSko^Pl%5TUyw#qoN&#|F%V5*@tABrk0&HIkshTIuY?tuXIZ7{o4~22eOJdgG�s z8D`ME;UXGZ&xY#W-jYR9Tb|T!KC;VQ>TbkZuH>xj^yyra8P5!J#)oa1zY>1MJ$R-DT5a0ea7vCBm%8m{XYchY1~=zy(NueY)}jm81R2 z+DT>&J6PrlU@TwUm=HCwZ-?eW!oG)0DX0UL*1 z4BXaUVH(kc$9g4T+f1NrU2Isl_Yk6yr+m1Hha_(uXwCAmw@1j|QO}E4y_!!n7X(vJ zWi#TmweFsSiFmPM7K!3R7%hGVOmkALak(B0zpo-=47ZSaf}ws7c(=Q6ALqXwYoqA! zVvzmn?Q^V?ZN}#jyDb5^%`m3aJ&9ZC|nCBOh zkV;WZBVBt-AK-PbBOE;L;Kl}&s+l3q`Q{pK>UwRZ-ZrkY5{AE#)~dIbe#Hb(@e z%@11QXgasm_oq0pp3`R`lSiW7H8o%B_E{w}n<`iI@%lXw2_e1GQ({8+ZQM#0M3?Xg zQ77(%AGmqU{^p->Y|5he?JRF;Ev8 z8KY=kHYQ+rcM<&hP7Nb04+P0fv)ALo+q@rq_?kDh29dTB^io6J)1=a!NC^N|T2+f4 z-1ekxLsWQZYso4%S_2N~e}uyf%%IX!tdgmXZ1T68OlA@W{5T6tB(^L9q!8r#^u|!; z{k6kubI70zxGpu~=dQ$&>adBnV1ak66{{J{f*#-I)$_Hmkiv!ILYpGb5J|sm0-kucIA$61Hf-T zG@Z+BSOAJ$c&R^%YmZE_cU&9F& zB5P&YEbZmb2pndid7~CM^wzwFiMsfPojv)Szy);wgJ6++Gwq*C4`?;G8kiR^H|+T> zwTcztHB)B($_7u6eO&8(m#aCQ^eHN>SQ7y`JrWjDJN&5Ks`|w9x?D!#&*!~|E?KV& z`4dPqGbhfwTef*x4)lQd#rUOQN1MRGr}si5q7BvRoxI;8`rLV~!bXT=p!Id3eiO-9 z!syX>^_xML_bwa+QI{uSM!q7UYQBqq@RvJY$ktS4WL5!rRFj!}Ro;aLsS~~rNn@~E zd2ZsHPbpx+&dr^TAopRCnchS*FKwmCA)bK)NpnxoZ)dx;)jaP0r2#H4COrNa zScXG(|7Cb5g^8TefvLFt%$oQ zQ9}go69JyOFbk#~nfuwQpbkR`PH2SLk7A=~+h+76`{=v-k$*O}{2v#p0RedvEJF|w z5Z7VA>+nnj_dF|drX>P(W;4O3mTPuKFuTa^tWO8mpCoY4XQ2;LE$GfHl6Ljb_K|{f zh0(P1?<1&L1(@-PjQnEgDkcMd-&5BO6Oy|>7@-aXWT4O}!th@pa`Rf#%=vIZoD%se zVvQ-b#xQd`N}}>oo)ZfgUQ`Y`{>`5Y^ChVWbZA&QC|`M?>(VHIf0_qT$ay`yubvOr zl?hlyeK(NHdj38rds49>u@4(*J|*4 zeR>n6#27jDz1Z;~KnQCin8cyPM%Fd;xl(rNE8%!;sqy=SsPYf!_y2raEy6Blph;sG zY4!8*aSq#_ELW9`lOaZ-v3m3BRL6^wGYNO`_CTiWDL?Ai`U=p~c(&McygTW@Fpzky zz_t6}+asZ!6&p{7x{4q&c^s&aXuBKulB|0gw?{N~is{4Nb{CeqNH1Gqd?*=IkFhvX-MG&+#V0?nVgMvq`;KX0Fn7p> zBw)ChlpE&08IdvBOQpkAaxL3>3Eazb<5X0`%I0)*6`7C2JcWg45g)6g+6yz(Oe|uw zwGx2HRPen$6sSl|M}J3z~SaJDQ?6gToKgE?WA&(aN78b^#mVhqoGCz#gt7yr)dMWgO% z!4ab=2V!Z(F?z(5v9uF??e*ITA>`1@a}~x5C>tLogt(|&c}ow*vk?4zns!%misoC> z5paCmEoLC447tWOL1b~sIQg~j^8078>EOPz>Y0xBO)-r@8a$FsI!AGqh8>CjbAJkRi^7;)-0W%bIb}v8=psdhgSR1PLYL!uv03 z45f=2%nU|%z=%oPm*Fnc-q#t{welc(16Hjb&_`3*p3+}Ix$sW9mU32twO80;b{4eBkkQa${%N=e0=L5gyjG|ZY z1_WEhlKuNR|F2Y6W@ir9GHT=D?beKE^J!r+ZT?nO^GGu5ijakx>y zg+dd7dFhiejv7$k*#~}d(}BZ@@S_ikuX($Frr3|6j;EX8wpCILQ*@-ioc$whIR{KZ z4W;k7;NkN6?bGiCZhzdrNUS57)2^|=7JR>PeI-8LNN+gYvHdjAM--LupkpVkE+h2z z?5IBjKq$BH&6ona#jF|q$01{|^NE6Lea#VQYo%!TVTw^q)#38qtwKk5|76Xqp!K&w z9xFz*>xw_aSw7z(J^lLa@2Z%!ziNv)!;P?HpmN{I3xpVu)6=fEWLmzly!@0VdXR5a zdP;on_Lq$plnB&y?lxpq`vqp6F+T3@oj9i3o#1K`mTDLuOj92QzD;{E4|_*}T_LA| zopu#LvcY8E`{RQ@;pnqSlhmR{b@r`uH_%_Kkbl}X0p5S-3o1f{gdI%@uAO@ zx8A(Ed{!XB%?3nmvDeoW9#681(6vF)MrnmnkuB!Ar6E$Ko}pma4?Zn=0qVvrk8vWu z2h=0h$NdPlhJ^;jKMHH_ENfTkC7HdKfQ;TI`!cXkC`}a5-8sgQ1P;_e#})={yA7vZ zRl|t3V$(ZLNBgfHiv6hp^K)udF)n@`PIxaP)qE6RTT$Y%#=0`nTiU$09lcx0?$CIJ zKf+l(w&m|%hW{HNP~N6hxm%QD;)i>^YVge!#XM8HY0ar}-?Y0?K|}Z(0TGK zFk{0JIbfABRcvh?zb@SXg3(9JqYY1wZug5Ff7RuKN)u8)h49N%9+l?Up6&_1$*|>Q z)<&EAjCqRxO;}0&-klw$AMy8X1t8bAv-HzuIYD9M0!D4> z-8XY?Q-Pf=1lN&4f%6XsnNZhCSLcr`=cFMYODkgFbTwuTtK+(o*@7#W$)Y2&F_EPPoL$4 zOU-1U9)2c-{Q)MGd1sN*cDc`C4VPzOmhtL<+OGMi=UhTW2K}ctpj9!TedRMjw+{Q* zY9e6Od+3R#t!lHQ(!A{NxwY>*Vclv(%r3EulQiFmIW8YETKr^sN0`Q)0vU?>V-K#C zqRpx^zRy9*O5|*~!sN{i;`#AB$BD(SG!IK$tlhDL(#l)HpKXLh062n~J`p#Uvmf3D z4fp6h$!SeC4bB(n_#!FVQLZ}DmA|63?l&kL+QwX!<%_FKX9n>6N=$qa&3T5;e7u%W z^tkJJuq?6Man67PA<{uETf(9|d78|I#j{Afjm)9*yoNgPTe1H+7nmL`%J76gyO@ZK z-^e*(A=v;`39!Yx4YLZnJ3-h^pYQiwL}h+1T&SXHpXqa7kd0F2%}F}2fTcx~cwaH$ zV6i{LQ*{9mkl*E&jE1y?jF)5hyc9FJpIgjCkwe+}(-1*n0kFp4YGB zE=hr{19Y^cfhLBcdvX967&U}w0qT1qt6p(#D{Xd^WCUu1haPrUU|xfC8yAA$BKvyT zEms~8xj%l+LKwhQvOxMXZt#Yv28Jps9lL$-s48kpOt&Q^+uL5wK~5!fUY2OkxeK6t zD#0^@mJf$+@=s3u~|H6On<0G#v&-q7qsWf^Ip2*a)$J1Yc>JGA+(ZS`{@)A_zJr%(R z^^^XcSquwkcbExT^#yz6mc~gVM(|v>aAF-2=G6URmT&mQ#a3G#{G+Nl7~YkvUr%~c z>~TzZi&Pd?(1=)}pkUvK^7XjnK@%l`*ZQ;IvfS~SYUQAprh|a^vQNae83hj*{r3BjTm9+7SX6Q?d?>t=dZs;6qWsNZsSNRyEt%o)O z`f}>Gk^)1Uz1k(}OLKhEG&CA)eQrDPoHDxqQS4Ndj98YzQPv!MHh3}05Bqjdw=ciO?t9V)%JTS1!RZh^O)e2iS**L4i-tF=MDN)6HvkE!;Ff9svdx?9B zHsHN4H|?_Bo^nef2!!k0F)9&QMSvD>%rf01rtyf)b(2A`+;5pF8jaj7fO_DQe=;-^ z`(;eJddw5IFvgSXgEs3Qg2>Qj+RcqVF4K3Zl1I(@ADVIFLp-0LRQze0XinvNzBk^m zP-65V0|*zi)sP9V)OZkXFPM>3G%y9HoTjzIJI|k zrVs%jsn$}sEqUy`u#_89!mrD}Lq&!h4>IU(os7AA(*lyi-z?`vrvwr1dj$za%?~5t z-A1?a5&V@GWPQ$ChiKm$X=8W+Vv-8s6ANx)LYOZ+qI}RgB4^!|%#U&WsI=NpE`^+y4w}*9Anm)PKKsqj zglH@E*YWY8&w%k6&B8NyV!n1$zY{H5BqjHaM~^quD7>} z@0s?$fHy5%D#fRZDB6A$vIH6zbSHtHtEH-KgbCOoDNcei1*%`Wq8(G%ei@%bko1sm z`xJk}E>pvZOTISKUJj`10OCBLI{OlJBKac0g6IbF3{P`+IyN=`5}Er`iC-O4znXLA z&W~fv|Rp;k`dNtvU1!lm|2@T*m{2P$PM!A6F6; z`-zZaFjT3ULKvM?FjQy+j~NE-mIh|@r7AXj+jBSYf$v|77Tin=kykZs=DSu=wMZ~R zkpc~Mq9D8wxyD$-W;z z-v;e(bXXGr#JT@EV}RO!y}$+0VT$I#&sWnx{q!2!l>vQp*uVChodxz|;Kxpq1Cw2U zUL5I509q<1X-#@{gsS4^5h?#1s%OFd|2aaZ$thpk95 zrbVzE*@L^CHy_nq>u+-d+bjf5D(l`|ezYzts`XmzUn8epo`xM0+^g4HL@v|FeTKef zSSa2Ii`X+@emHahtzIu!WkkbM{cIw>xQlf3#&EG@4+gKOOizWZt(9X*GZyAL5&iYN z00Uv+hDn4yRWU(+^`CCAGG9n3x)13{51-zU#Npcn{s7g`q!(CF+s%(zO{$MhekBtvNgRD!B)#CF)yVI!gecVKshZUR`w!ND1iNUSmlboM;4=#vQ z$>f4Q%0%wx?G)&`w&PyQoFq!39p0v_UaT^)_mXKSPh0>8dBtuT`q+pKr>#U z5dB8f(jKF@@|#xdqxYbS(ynBVeTJ4h(1jN@v)9f=R?vxd4{5ZSy<~Jw8+5P{&^xIE zdZ$s5SV5eORMwc~tmNO|y+zwxbwGXQ>8LOmsh0vpY+a(f47TG!;Q3VpvAyDyaPt9al^c9hv0O08)TOcbDi=WJ&;TT?c}9S;LAFIGv0?^& zQ74xhL{k-Nr@TVYh)1a5mCg6#h%Vg?eQ3DufniD$A#is*J$xy zEUx#rc|a-eC%%inf6|pR_PjY4B>#C=XceEPMt6Dng>^9b;0U@xX z55M*80sV9UqqCV{ZQD&K44GU0>GyOPi%F8oW1J!1w6(}^qeofs^recj%J()CRJ2&5 z@TC$|+5rR?<(X*wtRtqkZ8ExIRRS_=|n z-FprqlF1gs!uYY-!&l9bI5UG>2tCEj>?L!q{y7f2d`$$8);)nXyj4%>zJJ%@b3gOP zeeaU;ovo#7g%TMl0mogE2yiT~u0Q_>WhmGU5nU;Xv!KQI>uCp8SLZiWi-1M{ICP%R z4@@DEcde|GuON5j^1Q+8D}ZDiY?_v|G+6O4P~+*^+yU30WXJx>DWCewagD@b?L@@)M z`o@0+mD;ug@PFfS?!wh_r$Cn=JAez<_X-O>$B_POx}HEGZ7OVRZ0)Gn#gTW zInXQPdq$2WNd4elsmBRWV_R5kaZ#%qm9NF9Tv3=G~OxFR>;bt>@1n4-9{;%IS z0Y>{^tU++~Tcr9binl{ATB&(Pu9VjvUb^GDgKA`>XOSwFq8}evK0s*MC#U{YWV9hy}UC@)sUq{cN zl_wh>$_wyL`qxGw7`jSwufynB5?OG6gUPgQ^D^Z<*(BNWQco*tlEvE;I0zaEW(#Qi zbrWsrmfxXo14!6_hR$4i)_|Cbwc2w^2>ljuC{DzbhEP+r75%zI?4`rNYA??pZ&=#! zDQ+J%pJsugjesT^l8ecLIb#rWTg&g6O2Bi4n6>77vR)V_)@lUjBZSe5b>j~4 zeTJ9mn!Rb;ef&`Hnd=|iL^?yxiCeAj_Wvnj-M|_x(y;}OO&FDy*?nxq@M@e??^wgw zEf`!7-+P2H-CT#v@?UcwNAn6^YOC-)cdc=1}xJb$|B z+zTKY2H!hX;3#4FO5RbuCz!m8ZVO6EwbpbclqjxPN9V7b` zn4U8uANQ#QM$7*{WW9A%lmGudE=uQ+R9ZxoMoB3NMd{k;mKqJxB^`<&2uMjNFg8Yy z?hpkDl>yR7OghJa5#Jkqz0dFb&gcGz!_K|!e#RcpM_kwSC|3kn9J$|B_(>l>Kw`vf zFCN2POcgMvesdVTpBp7bRCf0&$aD#TAbLixv>F;8cfy;B1Mn%K;565VVOZCIg;XeA z&v+X0Bh%@!JI1bi z4>>qd;LW$n-KCBdxz<1mT>u4M?N%)&Gd#T$X=;(p$(K3uhJ>^_LivY!wuKB=7pDP~ z>s!~3AH(x|a3shZS&7V{yRXTPFw|ezGabSvuwbV=WBX*r5#XEk(XRB_dm+;#wemN^ z=gG!ts9&o-D&As(cNyr!4t&zXinuL43E48NaH+YQ7I*h+&fN8~HLjaiPrszZ9eNzP z8f!kG_ak*3`B4HPpd4z~TcEyL^sPaGTOD^*sH5t{1ngEiCPxT)%v+f$wH5`@YyQy!jNdrq&8!ITGP;@In#^APH-})Rks0$tm zgDSm@BIm!02kUjN6|Za(8j|%``9G?RUe8xjC$c6@ei4R;7AcC{w|E-WK`KL9XH4_O z<5!hLvlvK%2!YDNxDuHXRhb)PEIe$YZIhE+TI-@#AN9VZ!K#G(XAnn}?q-ccV_%YT zQS(KEHaHtPS4pdMHlqgM+8;a{bLPn7p!_+p2_-okF$J2SG;K$U9uHqs0%t^JSLq+T ztq<4Tt`Kkc`agqxo( zp3LVc&!+-07{Sz(<|EQ&h`?Y|e(W|{0AYfrXjF5y(1~6{?iKs76W6VWdpsRPC^0bF zI~DAaO?_Qz=_va%9Iv#_$yYMHumzDj?ey94xifymJetG*?WfXwROD2(LlBqQLw>nP zt^?nB(ckC4k99VaNb3sW&3_-G;xZu?UqexMX;}57W+oKnO zP+~QUA)$)YB1E*PC0U(ipQIRbX1!`GtOMLZ`c7m~4eY%j@q_maO+vNp)o|+e=cE1e zOba3=drz*8t{lW(ou9O%l=^r@g}s?|OvdWzK$h?*qC2ZJ{c|J`Ky=B=YIjOJ_UmY! zIXb;f1z*w09A~A!G|fD$mnVv{Ewd_p*r0bN=qlGsPkwmTnDrwoLbwflDe9TA!jh6> zl;V2xKJs+4FtMUx#g&fhx|Zq$J!=Glxbzz2o=P%C25G z7WLwiZTnpL@%Ody)`}T&J_Q|51nfR`px_620mCGr=mj}45WoTqa9vS`edMbLd6ibG zQAPBP_%rA%FxS+N;sULF9vMJOJ=7GzhRENj>wH_&<*T2K!BC>dk0|DjXt(7H_V}Mh zJD_bAR~ybxOotY-aLlHPL=fH`*3ui+m%DXT9|)f>tp;#@c2Bq}2q>Q#C{-3kP!T?cMZu5x8Q@na3Umf5I+U<eCu!WS(C|Sf+qucpBUr?WJm!AG=En1*weSW7xZQopDqOIEJr_uyU@~-c zB+$?9b1|fIC7(2j7MaMLKiA^LRTqi5Hr2V$L7ZmmZ>Ia8eJ6N6Akl65T}cx9YemkHfjUdw+^PqX?ps+E!UQc zTVASyU)^g+B8~N6GrLpP|AopwXTR@_zw%ah1Un(re`B*;BrZXO(0)+xwe|GaGld3< zA#(3w{vb@Lhu}tAv*3ICgTonoRTJ*~US`!;5?KY&jbhK$l?Z z2L^zmM*kH{dKR4KH#IYV-Z68xLlkCfq9JXwjB@N1cn5h_b6u zGGGM}#8PYL?x3ErG|1pZBQnm_z-O8&vVTpTJOg$%_up*TyP_6=W_Y#|!xMV9+Gl{`4rtFRT29iex)pt) z!xoiKKF9rB>Al`=Y!1SMc#c~D{`_>Tvx7XOfr?NSZbGrZ)Wr$jjU2GOL+@sYw1|^v zeWA}B15!sq;#?c~kB^b=u-9s_Q4TR-EN;>7+xywsld?RER$Ro(l9_eCM)h+}L%cf* zBWhRDD0ZcOx|VO0@a4bOI67WcE(gWoUmYsR%GWc9f#e^f=$_29uXJKb6Lkh-(X1q` zS%q8e!aqnTZvg(3vLFoXae1INOW=Q#DpXF-qNy zRBUB9t3N25w$1OFQ2XE@UqD8*RUDY=TNV5yL-bk=Y_!9N;kGdsmtJMBChC2`9yv~~ zOi%}45`}l}qaXP*lo4F5LFS%}TTic56!VFS2eVHpy;@LIDzGnQj}orqcz9@2OdlcN zqUHEj?2X^ygiJJsy2878XZF#~7pNd$&)!}N;T(GSZl_1b{iZ_8x>3x&F;u+f*s8Zc z8*wYjaElf0D*Rl@pRO!H&zJ&Up1|cS`A+oR%wf4RWNkAbf0Ag%c3|qskbA0fL(C&m z26NIIBC6`0A$Q&UGZc-YK$HwNy8M^|IAxOcZ%11@t$z2-0YZ3Igm>G>7Bgfpddjsp zLRywOD*RYNnboh(2qnXy47Ca|db(W4ellDW)EF{(G3xz#JxXqtlL4R$x)Y+BY!L+e zGl4>r%Q00UC;E&FTZ;UnQeQp*2P@*=!TOdw81x8cy`c!dPKsGRB`^JIyD&u#EFn~Y z@X1;&o5HD*DXR$;?ezjQ4Lp>B!(N-KtSdzEWkFpyNPVycphGH&L~%mL7;0xAO!rL( z%pX@lD%Qw{PRXf3hMh_5yACuFWG)m_WZ6E^5~`-ol{9H&_aGk*)VAfw^vos5vxIZ;{VLX0 zQ5aT?C5y@`9n=5*30D?qHAP*|Q2$4+{#gVO>CXNDTmsN8iOKc<_W0_?w~R9p zbrkjiY(xY7G>OXtzMf87r*io&TdzFK*OPpl6@#hKm6ronGBUlNZGog|(%YdZx`x`C!Hphy41Snu#e1PO3ir%_w9tfcQz8cS5zfy*JJFaH zHuZh8aORVJ>_QcaZe+o!o`yNFm!J#n3&Gr0W7gb2$(OaOsmNW@^AG1A1r|niJo54* z9&KPEZS4G_;s3Nozby7C6eX!cU{v{#LZ<3{Q4ui$fPX7B6b%MWDfMcC_{ucsOrRcPzk zU_IzgNOJQ>4*wwo^v#&>h+n3TbI$&)WhdV3pCH0L-Z#eBnbG#8D_RS3`5g1w4_nen z#dfFXeN-mUgM0;NlS;d0^*;+^1`5DpX>ZH*y}PFKO9vrOa2QzP4)p!JT+AWFfLDu^ z_A>JdsHI#cjRpzGVC1}yq{1t&TGLPV6rX}uM$7JeIf)!c=kOWaT&OLhQ`&fUx|T1v zT|Kd2C{yKlLm3TvLmM?8$Vaa$7?dB8w~|0VO#{X*mtSo#X}=(SX&`Pmu}Goxv=}1u z!od&R!pCn=(&*i&HyiRA9^)C=Y_fPN;3xhhSfTaeRG?!Kg5#2sZIh<&T3;TTB>x3L zNXRpMUXZ3BR)7*4iZnQrSV-2-JYbUlNQM)g(2B=_-1rqXqwQouL4gw^2=FVDUdy{z z3a+zPz+vkK-u$;LDrUgaGC93yp_aVeoM><=Hidj0G{XKOO@jem%&nvXaRAV!9J`X# zz@HyP-AsfOP_P`K^u)2smGDeWJ}EK_3nF3CU|*7Y_W**JDRO2{8B*Jk{u1BMw9~MB zbs{1+sr1Q(Z2pm~`i3uU?!F<*B_~+;=B$0iQ4@z~aiX!N0_1vZ% zTdBB@n6IJ1YXLwC*NDeZMkhY%QH8Xyo?t;|0bY?*W7Ok_v&EP6fhm%inUyuE!qXIX z)a}aUL0Nv}5Ax4Tt2do?8w@Ig+M7ypE~)U_?6elfjAKrWbeAFhsNE7t`_9L5hgSR^ z&C@Tx${VFwfcOpy`G%XYH)*L}BA+S7-POHRC*r~kpHK|VeHtD}_Bc!e4yY7mq*V$1 z8MPH5y&DfY%I_vz)AxQ*H#I{W=L@||6_0YzWT#H4?XjTGR=$7x&AUd85xHjKl_oQ8 zGL_!Q6}!gGC}ZJ+XPBE9$MA=iogumlOm%dW1qQvw%u!eTTekRO&UKDl;Jd!Jcg!XP zA#>Ll)Cz9$aWv4tdt zL$UiWssQie63ALeJtGqaHNMVzGLcocar#ykC^KmoZ6X`Ho*>~Rkee#KRFi+ECE5ku zNtl&u6Cdpq9EV-EaxkPCe*$LUa|KbB=7x#8lV{Z2X$4VWtYcXeRlH>weP?};K3EcX zs}49K`3VkTFiI`krYg=5A?BjINow2N8S*=FgOgjO^Na>kJ%89`(wxO#TnH(kV3|Zt z(xG1liffY&=Rq-qgmt4lT+}gH2~*5&e-T~|8zoJT+=@tloT!zJ{BzG zQ3rLyYSf(~@rtV?odmu@Ron_`R5Y?`q05Y2M71K;4$VvTB-VI71Kl(wH*BiHI=MBa z5~<($&)q=S_o1O?xbLr-x&do#{fQ6dw> z{~VGYm^ktzzN2`pUS^AFdj43rTpV&ZFK@4<8XMo430b>O3W;P08^nd-n(iq~iyd0F zR_c>B>uD}9SV*=Ed$gMje(M^(5-Ow%|63`%0)@aPi1@TZ1pVEJGB~;#WG=EhNiZ|xRs-CV{RT#{>kSJN z8?(M5l$A8u!yio;!JJp!cv3kE^OeZMwLuXSvO?36vRc#HQH~8^Fh+`?V7?v4woTFY zWjHGCs+V`7L7&3$o90Mxh&et2}aI5+8A`e7ddiYRQ zBFarl%us;K$dki+KJ@n)V-A0GUu-{e{x$!%0rIx-ooPu!@6zpEf6t5WN|Th^g0(_$ zzSc_2Vt!3QAq?Km*^&X$C{=5`yH-$YZ0P0~+(ps7bF`DS5a3dtWe~BD7MPelm!#YE zpvh%BQQbD^>DRY+Nx@ZOa#vebgVDt7l?x;t_mNQlzgc zZ8LW<+JLBmvW|xkZP$IDX19sohTDXOS6{Tx?eZbJ_zWu}ldP0bc?1yoGU5m(7l-@n z=1&HLy$royIik83!MMj%-ZZlx;8&GUbP;5%KaK+%Y@T;qX@>Tc^4kZGB4jZ`qIKKkEG^fM7geIR?)r64MhFi9zyIP z+(y^zVZKUMOTpna|NA9o%;v*_&pekT;WWrhY^A*k8#+s-qT6Ee#DyaSJ6$0k%dTN2Hx}1AsC76j(@yayQ%U3oWl0dy z1WH*s?XhQY5CZPJ`2lfCpnTUQsGVR3h|M+s3#%p-NjB~xxcUddTBO%N9fbTr)8u!$ z5qG<^o+CO(vL#zO)WDNg7zJD=Q4Jm!4hAjKAHMt_v+NB6vw^fS~I~bS@VZqOMH8XhLH`~y& zd)XlRn}2~Qt3Hkne6Jx-m*D2r_k!(mE)G*cxgO2_<{p+iIV#}mjRh=_(wf4WTB~I( zk%`HQ!mq8C{Ih4hi=-4H0*KvRC2yi6xNo=Z&r@F=SqY!H2*r{ng+W31T5Al5lHff# zkm0YGhsN{8QPo-hK6`~vz?GAq6~ezo@EgWZ{h)Eq11oSqr>Lg7y+vn_9{h4c(aw?_ zWo~|YSPDiNzpu=HjUWLk^RGPsy0ZAl3jqFf_k21b1(#1@j7Tu)|=^jhV|ZR&Y9XvSoNZr)i`GP$cJsi=$hb{Pxc*~*tflwDXz zz_dmA&?n$sn2ricoeR`yD|&iEYfxr=;+`sLD%%|1e_F@6x+;Z`LCvjb!v4TJ9P&rnmd^&{6@4D!Qs@?!z01hfnI7GXbFLN>>F~OV6+- zVv0h4eL)r;Im?uQ8mO=g1Im6Uum2^lK0#!rkS6_6^&t4b7UbEn%rq&(4hkNn#2Mky zLlyT19mz=mO{DuM`J>Mc`0!9D{d^@98{A znmDO?rFng8KZnLPWcdAgf1Ko+pciRYgiJT7{GB98VkhVpBj@dBc8Bu9(BxW~b?3sW zYKq!Q>jnGP1X$XNYvcHoo0%Z(aBdqoFRf^+z4?d5tNbDp=(O(+>M{TFgn?~xY~^5X z8fr1t_CXLPI2;a9;`#2DW^M%v^YD4X@O%u89r%PZX44t`U40EF*8ndHs$>D|>@nC& zN_f4!J<*_5TvTA*b2~NcM(w%ybZoLhh%Off!gs=zZDOw1I^Kc&MnM?epxqGG5K>R{ zA`dK5rP;#kkrxTJRdkbzfueqO*9Y?X%aUd$u4FpksQ29KLUUDZ*Q+Wy;|wTCfyck9k6ez=-t4r0c#O*ss$ShM~mKR%rinbP4aSn>o}{sMRab-tX{kc{34X zva2n3M`uBO!C=vqBvL}W0xG#gsZ((KYL=A@|JEyfU~%ha!x&i8PS+la$5J3ew}QrL(mdq1NZz0q9m_TJ+_|Tp><>q>c&usG zj9#tjI4H*DE;$5b~*6oLy(~>P))(Ri#O@##o8MgBBw%2}(si+_?fjq>;PL{N{658hh9;|qJ zacspN<2098_Ei8=-Gubc4CG0Iu3!B&33c1_>i*C%?+peQMvxf{!#cjUhFth-i@!EW z69Q#5;xcaA;RMlwsldgfi!yY(C=7QTZT<}E}18?dZS?^%&|3Q>Uh}A$W+m5Jqe`BjwBc6wnN#3kX0$A7 z-uoA;Jma|CaKfw3e9zyP#VVcY*I_48uBMzrM(G6&)N z=rZmNGwx4H{vTtUA|HMp;`No9$sN$#W|qNDxOsrvtc;5JkkeCS25HZ)y6VErI?uc! z%R?fPR?^lZtgrjJ%)AubMm;V~7xy~^_c~`#bOn%J1=-AdlJFxc^=Hji7a(mKW8F8R zApRRi?1I``rvlTGGeNTONp(~Z(Nt|^>Q>zo#@2k|4Ba$w&_He#G3sK>T(@EK#-wD%g0#{7JO8jT|%2g zYjt-lNKNIcZp`94cd7$;hB+Bx7Q`u?y{Wn+68J-TrdN0Hv@UW6{APUcLea8G?ZNbJ z{B?doxa;EaFyYGdfA4=Ajp5bP;!|K z_|?)z9@1%@fw^4+{;g8$$w8SvaPpp9@M8U2E_xlb_C$c8GAx_qW$YkIH=J&Tukk^| z=QX6on!IZC^~|ckqE~^h`TRR%1%c?RVrOz<7Y-X;q%2Q}!@p*;{q|CC3W-E_^F=X` zW=@meMvqB-H_Ba#(b2nLUm)0$?4ltwV!r!<#vq6Db;sJvElS>YD6dgK#E5IX9$6n3 zLHfqFN%e==in9f}Bl4#+GeAo{Ei|q4uj#6rfJ^(S;yTs=a*EtFh z@v2=1$D=g{S;>j1bY;p%bVO}}WJZ}i%1yE&_^|+@P0}C%Hq;qFstA&>R&8kkSDbn} z@N15@Kf%(}CFT#zI7zl}l7b=%$O#!(;aE>xn~6oo$H}iAAu|iM-$+iBSHHnDsV5p!4brEq_T!&{<#^ zc$lx0j(g&yKIzdA+#V9fQ&5T-mX$J&u8cetR7=i{qgG?^JBPr}QRJW*1__^v>%z?P zK;XLJ9~7P*I15%U#7m(*umASlmG2HB>m1#pY*agIjp>6&tXNGH!O3Zv7mZL>P4z7P zgS)cux;e;>MaNn9q7VPjL!(;O;i(s|7S0FvyW;{K055O%h$S-a<{ultxZGsq+i;@3 zpVfE`A$j=W4Ojh_RqxN+B5si9!SK=U}9+V@DaUFr6OTO9d4 zv_#x`!I|&`am+li4942K$m-0bm0SZ1JT-?nhR>yh4X2G#B|_+Xu+;)8!a(%wZV(kc zBLU;=&&^w^k7~R&<9<|A+n-3lbXC=_p&{^_xciqDe9o|cl5(_=z0bC6@mRR{YomZ96fz7+d;9%wW)4j{nfza5{t+cK5jqK)At;0pU%?d z9hzl$wojUonOWnZu{cH(w(Pk9Yp@OUNEV*H7rrJap3heQ>JcdylZhEv>d%ncK4Np{ zA*V){Hy!20&XEpwxeiiSb>C7%%%{R~Rz{U#husjN43EANk;I^&p1OK++&r~!SoAIF zafQm7n}WGJnu1t$t@U?Z^P6%0`Yd-|$`1;r6zCuf1f-1u2$ z7hjQcB-G!u92RlIn$L$KTfrBTA)D8Bj5MOao#TZoa+o#wg|j+T9q!~kO*F0`kY-DD zf9@w8QJ6%|=9vclnX9d7jQ8jCo6jpCDP^N!j4!5|XSd1(7$)QWGUsjkf|TWirm5Nc zj=t~U$68uN?qM(}GpxnO7gjRrKQ1T?zmVF3bxl@NcC_x702y0Y5)eA-UITDo41iyA zvC8%ez#sxK{H=$MP~JjotIUel5gD*vYgly2nBj2n0U-dFhk z@8SLTK9&Ek^V5?dKr(8GjFyKO0FOXDwogj|wMgJ1ud1yz0d&j>{>oKezRUHVdM4oR zQvDUA`TLQ#{&t_LWjzYiyadMpmkq#er``PVH}(Y(DY}$@0rCI7|8M81W`XaQ*`B}G zB}zBW*Gb|p!6MGF!7=fHW&GWO|GgP_`~PWN)hsq+!g%>{g|x}nGwE)rQ38YK*22vD z{`Wwaa47B}#_UVKoR${F!)p(?>3^v-oA)*G34o?DE$w4i{3Y_4IO5+;moNRFmRhv~ zD~|w8%ggI;`LI?U^Z)z*-w6IqxMRj&;Kt?W(CU7>bV{4leEzlWdZKtoPEh6DrI63x z%W-KUZS6Hk*XC0cfV}>zoC8#7o;olwdKf%)AIK^Cn&YZY!24%tymP>~4kwwn#p;|@ z2JW*v{_*=3(+*c*&*gG7Ln;4uD6&~++cxU>ZmhnfXr571=jgCm_)R+8pmS`7=Fa1v zT@?rtt$Lsr#_hdBKl$1fv-6bwvjLl_SH(gZLLr`>0LRGYZ;BUqYMNh3dEK;d8*+3l zy;w73)A3q*j)&TVgK-cl_M8%#a zLx;YN4w=-2J`va~Lz-$y<>w+zhN_t88%iTLH?*c8uUt!2x*B!R$Ih*LRk5B$;@>ya z>sDB&CI@2N%&Rqx)iX=gwK{qwD!G1uOwz&Yv=b3kpfnD~!Jm?bL3_K0$6CXUM654rxraui2uk#tBDisLh zHTLczp3-J%qJ7vv@V+iT!0t9iU|^jIQ7k}crd4St>#$ z+yQoFG%R$K3d!hFnoFk{HgbGY){`daT<0cyGwaX=Jl>vs!SO4C*@3U&x@l0CoYqSc zj$obh#W0f`?f#as`MGArI=hga56Al26tp~7{{nR5;<8|Th^%nMUxy#-j4&;HFv40c z>nAD5+!_~Y2P2E<^qP`$qVM|JH4pjaQ<%-}SsryU<*+?-CEJWHwwL?bxverv18m!; z3#XKTwrc?V2OPjKYQ6Sx^YaB)=Q3z2tVbCof#pwjs#817#i-7tA@)=;S)M$0XJjfx zDOQfc8q(IQ*S3S_dYRe|j(GvQ77NVzTlK$iTyALnB%hs}{nXf^@^zEwpU;QLw1sYp zuiGr!Zd;D}Y_5(9hiyGj3fX<*Cm8o`NpxULD9)Z{0ADsQn@-^#Hm-~j|9L)7AuJ$7 z4s2fFCkI@Gz}_@7>a`10i1--LB~1Ys^F6+X>fYd6mDO{9emtKgtNtLN z;oP|Q9VeXSwD^qJcxG|9Dey~O?J%{EPaZ()&&)d0nRc*sx-SOyP~qkx(7ziGEh zNTWd;-7^hZ-P|^l0yzHWUE~5cTQGT;E~W#xZWTs} z2z^za8)jIZti*b6$se56xrTO8AfLdscRWDP!N};fKK9UuXZ(4K9$VM;NKnHQc4u_V zFN8jp<~vP3cm}<9yU-t4n@9)gg|u=x9x;%mfO`W;=k(Qu^+$jOgHXu;DzPRLY(s9Q z+7q{u9yC1U)LhBIY$`2(q)|w?Va$3s%g$g?)IW*I6&~t`{oJ6SX;}=3W@&Amko2)iUjgclv6tm!h_6!~A*6 zPV?tGlfjfMr>}#i&U=&2>O%|S0gKL-sfNNAMP<`XJR!iuDeRO-^9!Hu{(ig~`R={5 zxC93+5BtA8t@a7n1!Fxa+6~H+jUDqH4K-Epv0weeA#*hL=e_N0rD>uZ9;PHn-k!0O zI-M&@2=}u({IX2@x}4XxWwWW)2dP^RelDgA)V4cIhT+Rv7TaWIM4Uqq0tn}+J?>si_(N^QW#EoHx+jcJ?;HdRst)#}- zX5diS4&9bnTEqGrr!rZlJ1F4Ym(T-YJaTr9!#N?iuCt7`h6aIa`D6%*_>`!BhYk<% zig}uA=D711CO3Cjx_HYV&G=a1>f-Psc6mkeCci}&32QUdeJJ6?;&*Gxj`1R{C zf!`m-DQLBCXA~hfez4nx)U>!`!8%DwWlC_EcfJg5^#3Mb@Ayx* z&D5S$H-am59)!q18(z%Jg_*VC3jCo3OZOPKdOr^- z91&De;c0m{jy{d-D8_+nq?TRL?oCRkU0+urRm$mD2r3F|HTTIU*OI z2)p-}dJmV8Ws(rDPL@nDAAz4wO&O4%YWXo}m-y3Luq1e=g=xXwJ z^7I5;`rvvs=G*?h*^BsZ6{lmvYTpV@)JBFTdv$yc|A-!!=ABxN zcOwP|1&@DZSiWm-It4d+o;8`Zo{Vh;z}_{T9scyfYF;jl8v3?xpTimU4sXjeYNY`PcSwi*x~8L=fozHi_1x^7uadwX&=Ww5N-(6 zX;Sf?U1~p2zc6^ik@?Q;5zx2R{jB`7ljD@>((<_B1531W-_?iVL%g=$w)4n=-{grea1ZN?m`D$?7)MF3 zrqIal#Rc!Tx~FOZmTT~0yZ!B)2ldfOzD_Q7sy(v1J)?Q+_{s5Gj+?w$6Cc^? zsc$u$pe!%G6+|FJD%$o8-ToX5Z69eJXU|=Ds46m+<0W%kPn+kCb6&vhS24LCDU?)h z|F`WPiQxKsMtpxJoFzoMp?=~{&2ds&Ap09(7*Wx4g*?#unzK#0g?Gx!gr4*}`{>an zhun%;UQw^ZjfnR$5Uw=z(6fxwO+aar4Mpwd;_|g3__t;M_IdMMs>RU{jFI`^8D12bzV$JJb@eADJ}ZINy+B@l6PNN|vu7+A`*DH;&E(>g6C|!HH9Zt! zxcs@-|EMp8#jG=X;8s1*U>0EUuZ`x~X!yvNHIvu_zLs%3#h~1aqG&39nl@bX?7diB zWaeHC2cCCt1zrDXP9A@O*E~L9u}tlL#4si^cNUf^WCXf(>%Z(nh<$vKaUFJb-l+(g zUiJEx^g+vNJRNyt@moZq;z3&L zKlnZoj7p-Dc0W68&zL(J4C9WB$~0=nyXU2LH>`hf-+01eUBKcE$Ua?$lE9^FX?C_i)Oh^) zb8x9qh>}fLt`D|`X=V*kurq1duechWn$ooSK?-a;$Sl79^ZwH;e0#aU`Rl~Q0Y8NU z3+cO4b<5)y^}|nDgBKLe+nOIPSiks~SZPz8nEbDi6s>OFgqFZOt9)yt$@QPL&qoe$ zvKxadBqH<FPv(T6I4J#- zdd&nhSNKay!zo|j7&$j&JG>&8j*Ek~(<4t(nS^`cCu+w(f%77=3mTfHkiAU=)7gJ73Exj-nRBV*)Vcy0~MBY`efBn4gsCB;o z;Hi#!Tdj!Jj(!p-=7RXiJNKa-39D17!k>}~g-+QgBu=bUN&D)hqt%o3~wo|dq} zDlZhPGYNm%Xq5J*bZ70Lg|bLkSgn3lzpqEiHOhm;njf;$ z3%IFLd}#@>Q0|S|tLmu)v2!Z)6!-q7+t%J9``T$#G5SrS4#su#l72mPDxXVg3fCvw z#w);@&m3eC^zuB`T+wMgEHncV$6%t|(?HGT_s^F=i zN0Ni9Yiwi~I8=k|Fd}nq4)kndSu|TSI~Bb;_=BiHM@P`^~5-s{(mdPtEPYpt?H3?pO|EA8-7m3 z+u?PUR+&i-tW8ok?)wSy!0ylhj)a#*ii93jwV|lCVj!01EfdSGt9MvR`wcJ5G+1-C zQNQ%JI0MhJXSw#4F-7AdbQHu4HasYDemmCFvtaBRzyeH5%LdfSNdNb(FVwve6+LsY z!%II4xX?!iPsA{_Zn1_ZxO4M*o6>QRS?0xpWbUFuMrf70Im7-hOK%!jvX>#1hTW$O zkj*a?+$fv>{*~z5(WL;)QQ_^U@(MyEsHQe9&lqVV67(u1kd^o^8EbBM_J=0UaV$dc z#BArFGMT(Hy}C{Bg@Z+6q66WB8W)V!C+gXOqIQh6Xd5QBD1(^REV`-=GOer-A+Y$x zsY=b%P2D=rsmW1+kBp*H$UM;a!)eLI&+zj7etyd|DbvOP_4B0R;FCw=(i(prtBn4I zIndJjkaneV_zDtsGx2rx+7{hj)@u#ql}z{L z2RO&#IQ*~FR6VH`s^B4Q`{!$42x1j5cWz_#FEj#sPifk@P}=7%8(j#_C?gcYa?==* zw?b&^_Gm0Di)*qk`VUC#?efY`m$%ZFY1YdoRKSSQ-&UWo<~qAEiBXytCoQp<5PO;| zOf)!V`Teq@;2M&-yBHMKAjwZ%Zjm~9aX#Q$aaYPw0z%btEMCQLC+yP27i0cOn~e@p zE9mQKexhz#$zgf-!0UbUg@GXVO#ac)Zzd)nKlJ40znmQmAvQd070Ph6G}p@q*J1@o zw84}nqWQ|yO6yD$edecH_f2Qgj++M0D=gC|dF4~y@9=$@Y*;|yu~zorwFx$$);YOA z!p-?b@AIC)*fW8Y;?#9 zv>N!*$RTzLl`EH1K;OUo&F%sDD(^F->*i)E>n&b6DnQZMw!N@w-X5~pyI%ltkylkED$hrOQ zADV9dSdLKr7^)Q;ADNz;j~LQ7K7I545j3DwHQ%!!OhKC{`?uHFt-|t&lhy6+@5Y14 zjVlCOu!b(D)Cb2gKNExTc_rzu7@j%gToFL~vn>W)ym|4nGR6>SHYS+6YO?$;{p%}v zQSPxZ_(43ByXCH&O%UC*<(P^LznDkX1kj-7InXrS!=O@cF=-6CqVZ&D^>5#rxBpc_ z%-;@`+?Go}xngfjY>uM4-;){fSMjmHrDcUC5BVpHl`A%T@+^g7_v`F^q1MnLffD#d}IFQ1uqGeU|y z9URG$zkeq+x|qD&4PqH`CN8RgYybMRQbRNsgfK#G*!!B@Y|qG+L8;RVk$ zM(-S%jO_Shu`F4x&cmDyUZLE?*AQJ%OGemVSO5F!_h(1a_g*J5^!HA7O%4{qxVBy@ zZuPlkr%W47p2??bOPcuwAqCKE`g}!WX~stA$^!AKY_uU_{L^7(n9#=_wvEt5=O2QT zOSG%pngVC7Ke$oVK~|2}NhDU*guUWqsGYyg`g;qp0riTxO`>eMy zdZ%pSFo1VD@Q&>i;$m65?c`fn;3_&)AVd8!RYvkWi;)^vASaUMR`#h{(0wJ%c=yeN z#)A**lCg$X%uT8Ki31y@&v2D277KfRp*2t2D7dg|;(q8(U5D8@0O%Rm04$)_^2aj&@fp>@fD(6^t^Y~*Fj_f_UK z`!F>}oe$RwxpaISR>jMt8U`3QuTPOor7nRaZ-hTFfnAtmb&b{Z%JrDIQicKBRdz2Qi z7NAP7x5B)OL;o|6P zN21Pt-=}}GZ_KOQs|&b-9_2)G^*-|~yvsMawBE+0EA7pziWy-mBaa)%Us8UDrB`@X z%r1R}(5A`Vdr&Ml(<{CI&dXBrAqSc!CMcmLb1B!WohoHcTNOUY6fx6F?(-&3T4~5| zW}Wl^M#WbL4Z7;!L)W7YyUV&a0kvbagf`50nz=ln`OWzt26@MKM;c3vO#iv&& zekjIFNw6$GuBnw)vYM}Jy>%?%n`72IDt~E|M!q`w5kFE+*(o);#!5d&uSW~&nvH)JC09Wa_rqg58{lrB&HW%TZ$(Tk2 z*N1wEy6;Woib!n#i0@i@?2ii*WP?jcP(c~VXGW3VU97vVl3NU9`VGV1y@3q+yRp0~ z_l*t^;$HRT;!b;62UX~NJkcmnQE{f@Oz;w3U_(%;lG^T4FL;#>T=nu4CRnPj39iRF zxPL*luebD)aW7GE#>gAYKamhSm}sacFeY0LBGt6WAs1uiO*G+Kg8tNZ{wYcDU+U`>N#$Z9 zrL_)u#`j^EO3_|Pkh!*?7Nog6f&ywiT$NYTgl8;rBJ2bv#$DZc+`~}l;y8ZW18S5G zl6Q683(5Y44o+|ugXZG12R(rx`B>-A8z{_V@h}jKNK3OF?fO+>Usx%ErmtG477ey% zzo@HG1V?gL={~Y%J!geu!Y=cykNloM+vn1JxVUXA>Z}q;0J(SKJ??+HES6dt)`PgJ-_@8A0uHVm!>Q{$C7w9UKn-+sX!^qDs-hL=6Ozb04&z7fxM= z=0w7bKkR9`OLNhs(^ZyBbP`VaZ3^o$g$IF}nc(zT8Fk%fl&l-I#lwk0J~aE&?>}>k zD%gf!qU2;9J+=iP0`|MQAvFicnwxKK{Rv%v=BkE&Pq5;rO}VML%pZU_CR;5=zV3d~ z)q!?zpwq#)Yv8$q_K$~2)!WtMY7ZB!5o?`Uo8Zv|W_mRSv@300czowom3^k6u61Rh z#JH7DTpA1eGV~Rem8)9V=8@X85`8RO`v_3p$6*2injy!v*`?eoR#`4Ak0r{!ig)FF z+@7Hfw=6B64(uYMhP}}~P3sGrIqQ7R$JgOCG40KU%ApK* zG-IX7q2$m&eAO!8)lT-09}H&f%FUa175^Zqz7RID7_%wa^u{U~su>v7#e|SULUc^8 zPcw`(!HJC#NosYNZMVM7>CYqeqx=vHB0l3oK{~Tf6;N*n=!t){9Oeb|qNo$5{*kn1 zI94&*?rWK04v^#8wLrDjZLFiNEuxNBke*16`{4l91}ZoXKTZM?mEWrQQ}*THl(caZ zG^<3*nC-XKU`nztx0CHp;NvrMdJnK1=>iUr6{|l$)4E~kbQR3^&9AMnVb`c7rwu;d z_dM;-R=3TJfutA3|Fk9rVI?Ws@6DAbOCeBHKQulW8Q^uS(nf7Iww| zD(+)p-1b_3jLI^fq((6~7{l@s2iEkeSP!`?O{)dNJxs zQ{8?Y>_)}OXqR9^{KyQA8rfBm@*(~5e&9(RD#e^2NX7rD!EsQhk=F5>vk5(xWCql< zx}#*S&fyMGmHO+~67D+~O@xpFX()7?E<1H!E6aaf1F4EkUgvK`L36BBDKsV^?^ZeH z?<4f%uFa*(;#>lnGBt2%o%9Rr<3f<6X7M2Y1fLp&no#oihl*4xm+-y;XX&h|@dW8x zjhl}_0<^mqdFmNpP=&=p`QQYs(6YdgGx=|g){lR@S3UF!L7v@@sb+lTEu%^38w3HSqVwjG!_ofkghR^Nn-Yn2|mA z?$i1E=Xq8^rp2mdj!x$}`q}P>3!k`%lB+U5J2mpFYBj&wPpD2lP)$t+U^pF5b8|x; zYw2t-Lfti9f>k&(-4>=O0XHxuYn8^4ECjV;yRC6AF)3?I7V z!NJ@dPTf|InyU@!jtEVo>5`3?#Vq&k*V7BqG;oFUEMAUMsGrX4g_yYdU2~=n4PG){ ztL1#uHR&lh$J%tx7LY!1k|%ubUaJw0NiTEr?i1-syl@^$W}^ZWPj zAn4qZdCA4REgAFLTS|3#Sl$(-VQoR%N)xbKb|~~mXiQ2Q&!8stW&i{ z+k7otNLT-~!VoqrVkHS;WjbJieeVqU#$uP^fX}1-wwXg;%2TVrDg>pRo!s(2Q)aa(ph4)5LCe zc_h6Z{q(vXR=z%LyY`um23{-3$Q^)knwB1e5P4O0{gUB*{a1l_fV-$k;n}(ar?_u< zmywVZ2F=cnAF17o&My@yv%fBb%QMtnr`<#9=vox_`qC? zjOjFtaU(Wu7Ir8Z+5(-Si^a;%Uww0Mp&3?}2ezJlVjtYCiP*133>2U)+y1_4xkBKn@$!U5VczUipMO7TC-%pIrt{)`j z&81l@1#P+-QH}LMXg_J}J7ea8sC?H?POA+`8k|OS*uWJhP(;2}bSfNbR)?cwky7SE zRE&)*B6$_C`*`Y-nEIng_V3cjxOUgJBrWKRx0ffSCY@DD()hSP2J&B;4{^0C6b>kn z@21-%C-D3xy{x=Rb*ODrNJznP%_<6G1l0r?w=*EE1*whlLz9r3K$4M#Y=6|Z>G-^P zS(XNh1r%kqMBVnby{Alunx-@y>rq*v&7`0ncrpqqFX&r8=m#yz`CNUz=%hp%U_aE9 zb(|i7i(paf?le88)C+b1HL4_qfP7C``}%I-6nJurG{Lw5m{OB$%rJ{I7xM|F+R<R3${=o& zFy&6tjyn#c6=14lHAxD@<8~H7(T$5r0yiUK)@-k&L}+TbLv^0BC5hN^-U`$m6 zL*H*hujL8Nfp-QSbz3cYW|5Lo;o*@({uphlFFUtB86^DPR}R#}6Rv-5P~R+>XkVJ0~3-*EgGb}LK|>~(7KdM)OfTZ5GWujl;tATjT& zr)>M9Ic7q6BN=`UqH9Dm*eRQ_Yd#8~!!RpXfI4(?d6a3njy z0=qDi`C{-XyUghhs67f)@Y}bPe=za{U1GF&ywz=1R+nLU%ADEFgntVyL|L2gu*%kJ z>W7CvA)>_(eOJY2*!4=la&v!gWj&=xTbbBh^bFbSrMYP=FiyP~NK8mbZ-!Pyj7@yd zzULHiZp@X`;C(**uyR3g@CL=PGrIZEf@9gbBdR6Ff*L)SPrarBC-?g%(i#E-(FEiU zzz`Q$Ssg$}PLKIM(EL;P`d@kNtO=ub8rgRqlSO-#$s986fZvk%9_KdC#~(CYPdz@Q z@i<>}Pdb-6i?AwuXm})!U_`#|(S@45rIqEUV@iLfJf3g1<{yt0cttErlO;<3mW#1a zB{j^qN#Z3}kbjcS&j>vr5%3uAt@Gn;JCp7j9G4W__&);alC83yKJ)`ykmy-vk_k`9MJ0ht$Wk$sMrXA)iT5gPw&( z2UqzRiLJi(^v%spmGNT1j#?di<3;Q0Jwas zL%-k`!rdU3`a!gK?lgf(&89t9uaTzma@t*}P4TgxzB-0eF}JyyS)m+jFP0YX35m1p z7wJo`thR#n#GL*hg{E`JrT-Fla9wF%VnW%=Xi3E7|8z$YrKKlpdnnw_EhJFJ_y<@I zSpc`Fs240}ZvW0VVFz5NlAur-O5L^m2IX_N*UeUUx)o~+1UQ|-T!X7t3J1ferj)Pi z{mdtF(L$-<~5P|gHV4-+wB6jg;988i7D_kx;FIcG-b zoTJ+wY7Zm1GKjUljTOo`n3Sq7P_AviSKJ2e@ zP*850B)#2pJH_k%n)sSjuiBU=h<-$NbA$J??~`(m>p)!zhIz_!%??R6?=X^J`Zu!c z!3pI6Rj2{;O@o;_#`;0soCOrJz0D7CNU6?lwIni>RTggE1KS*rY>cyeX%x_;VIKNj zK79#PPHwjq8~2~XEp?w(a!l}HB1%kwe)-vtD5saY)c)H?R6b>D77 zRH;EKog2=%>s9nNR)d4JjPuY*8*5^XYSGh&n(OuJaB7Ox_sfG!L}qnNH~2r`a;bv4 za`h_oL+4dE{XZY3-{b6_Uq3zlSy*E!Nf~?q{di9^jQ+q$DEVXabIRu4akYi2xK{|&2sXeq~?EL6<&*1;GLpk<> zxWTQ-m=TULVm6NT;cg-u4J*tk&*BQ(u!0dClIeTZpMz*{#L&ZX08`|cgR--0(;g57 zM=nNZR5Ei_sjTF=#LwSOY?;*~XG+$RAb3fMMzb)p2~D3jf4h!_pX6vb>aV9lvpM zkC@M)Iurv%6Um#KEF868ehU_W-Y|S=bJ-S9G6KPCLOB^Wg)}d_`!r~A&}uK;wPu5Q zX3aNh#jM%n&c_KLAxBc7Yq^Zr%zzSqGFL4}=wUACx^_&nB%!FV(lvfma675`K%cIV zy0p}(J{_nE+sEE!D6^}I-*0AIk3iz_5?|7FA9{oG((wj-TR<{SU>JIu;=`X#!>`W2 zr9SzeK4Vsi9uGZZ9!k9)M9{`73|mnLq&Td|44`CAk=-1~NQV;hG|!3^uzT23IF8`f zg*2+0dkj8=Lz+p6NS%l0e72bcZGF2O3^YayV_Vh^9@G_k@#N%{agWbcp_?gn=}B55 zr7ENcT3;O@kC#|AVXv=5d%GMZT$&fmWsHa~6xjTUt6Z^5i(czpVk>l5vq%=Ko74UR zm(SU=`eIx@7ThgM|DZT}knFK4D0hD$8~QX|J{J3LvV$-&GcL}X#O$(-zfa}jTMv-V zBNkO3iMW!f+$f_`TPWk~P=T7B7OFYPluL4F#MI!J>;Lw!W}!FSOy*sD@6uWNq~OfTl1NE)q0+;zkX=<2j6SK!wv9TonIcuk@mU3hHa4H12f%g3qoz&d^39q){;J*#*3LIt9gq94t9x8hQxwa4#gN?vcrqR*^ykSL&{5S z>z;1Q=ArL4WuesVdpSiJrgr0kIv2a(&1h6==z;sxx9T&Z*)Y z#p}{`8P0IMiHF`8w{QU?4(8Ax+cnwAvJO%s*gIMJ=vSCwh9zY_a@rAOBn-pp2?DP?QA(eZklZ1Vvp2E)Y(4d z0PRL;6XJ%~25A@5*TdQL{p*%|;Skg0YfTQ_gMn{OIlmIF!x%qpwpi;TwR{mpdgUhj z8}Vfi?Q@T*YaCUUqc~r0xxe}50Q@$D;wUIY$TIr-)ua%T!zz=9ad3?cISf^q2zLykzLmgF9Wj}oG%JJa8z%ZJ^Wc}a6trXZGoK8hH%rXTE|;6u!rb&bI}gH4PI z6s)9i6fqQ(EQp|PjJMvW#|~x9{^(N7xOo$k-mX6uT4r1OQ{bK<*)V^RJ7Y$|wUis3KjOzW6HC zX5_;{0K!L%OA=~wk!(VY(DpKNIbJ zmjA^Kb|hW4M^q)c92d=7vzf0SRmTk83g|tqktR6DLO?ym|1GR?uWZb8?B~esk58jB z%`0N_Lu^F;p`v9{;*>QFVH)D1zi(=VDDXn?U>up6S`ak7;u;J$h|lxWLA$Hr?hC(u za)`b66%|aN+_%YH2O`0<$qr`qm=sSkGX7XM>HCoEOXn~NWZ_G5Sbpg$IunOn;kYEX zRD7g$uUfF5!16(2L_|?T?fMM;{>jSzg@$|ANniA~Ty5n}Aw4i$)ux0d4A80ao(d~; z0*N-hf;HSgRZtLXmNK#=s*`xsU5|Rf7BTI!T9Y|B>ej;R$p@3Ho`#fTh`h3FPJY|$ zH0DoM-ALvyp*3Z2}KePqI@HXJ2v*wTBl z?n2H{8`IrZEz<{RYe5PUPW=F4-xtU#`iYx|<-KIkN%8;J=r0J{au>-D?n!KPz+Ye; z-z)A+I1LjK2jqzVrl=UlWzYvo!sSe;9NEJt-l|%pa`^8Ymt7S316q|CT%*J}bB&I@ zVLFB+Dvl3nf3NAC?pUmD`E5y-p|NJs((Hx?oPc^qOF<6m( zeXSDKD=BE+?Y|LTMC;`$o%#EVy&6nLsra2G?rVYpp1Cj|-wd~ZfMFV0xW1;k=jQi;%;3M{p&6tZfUQq&fq4J9CF)}E2ylpuCh&4q5P zn#dw@R)6UiU@&zIHj~!ZW?oYG^2<{v7zgCfQ4Uj)7@6<_mA;~0z)9l`;k%A?9RyJRX|Br*K)u-V2QNnZ``AHu?2kK#qefu zpG@btAL|s!#uS+cg|MR6j($=luz`=%e~c2S^6 z_znOn%KuJZ7DMX52BrWNu;?UtyAqMxMHC^Yq>PJG$YNQRU%S_Rd3@;?=q|=2KBwk+ z4@0=8I=`zQs%ke@C!a%daC|aIRBEH>ZdSTYOGIC$v2B)zrhwC&mgtD!`0F>hfbj|E zpSe>l_Fr62bivZ-uMpP%`&;wyqMWur&MYkAkIE>ldc8b4nfe@}7Wf4wuG;)Aelj+j zzj3gd*=r@*0*SyVKeR@WUM|?JczkG4mJ~rn>iM)2VEB7FJuOg0WSvlMEp?WdbxmwY zDW5+T)Bwc9&JxcltZjRf-0Q>IsfZ864(d+h()1l|)F;}QpCZZp>{?&PSTBr(F|CfW;JC9BlDcSQ)SSdBz3E#Z^7n(|gETvFr0gIkK4AEm zL#SH|(Bk=cQn$YU9z*|am_C6qMo!L8M~d+ZUuX`l8PB7l;$<$3AE6(WeGBNo0VX#G zMJoen;}3w5nGiQBcligh5P6%{$q_7NMnnBanIg3;IE%Yf^;Acid@m-fec1k~@E|<{?yDr8O)P9U-v#NKCGUg)*ut z8a_U)z_L4gE&6Jg>=-1dvX`u4RLNJ=IZH>8AYG4ZP&dOOf*FJdf|ty8E%ni~h!)no zz%eCnsO|oYT8-e&!?V`g?rzkU`_y&~3;x8EBTj@Fe&KY9 zIaB(JvW2_zaNIIF5N&zp@z=)%MQX|$1NnG`ci90e8uAXem>1&zx?-6!PqMAa|(b@y}PJ;p1J%p8xzRds6K3h4ZNyM-gLjou@2r5 z{+#3xcLe@Ci6y&qeY;@6nl)v0VFWS7@w6b`Ksm_}eL|#i!s+uO>j{w}jjdIvG&tvX zI3>?P>eWYEV*k}M%t~}p+SS{yk7E4j8_-<7ylsX6D&q8EctC$gp<a3R&ai^a#Zm^N7lca-cLA#u6oC#T3cwCOb0Y}Yt2JsL4$=w51UW& z2reK6IhpFHA&HO6(SziuNbVvtL3(e)xK#GuoxQI7S%v)v8Dtm%AZ6PYcFP^l_cD~@ zp$afUtHm3{qj@Oit=bKI(e{I$r3(wOXU;i;dS+aJ<^p&9+z=jzPUf}rd)0PQSYrf{ z7CGAn>zcaJao6d%?g*Nb`k7za+#BCpD0YNgCFI_jk{M0g-b zhR$L5VYt+Cb)otUgoL#p=IH<*K%U?)-)xK=PcvO@8rI7k|yk+eDH* z7qb_uROjv@D4d1-7gsdFQ&OHj8zWDIe_km-*kKd+qrnfG5`hJE`ynRuhwmdWJ_vQC z{dKZvzzP@lUKPlPo|u2ty>j$OsbRqrPP7`Rfb69q*>W)894_EbE6Mdv`DXQK zLm5%zR2ipokEibJsac+L+f?_el<&jV8xoi+kKnHFdJO;X3K#3_*?-0Rx;=~{cI0}N zAJ@5+EWFPiq7L$_l2^AVK0MpqR+SU3Jm~Bhsk#QE4Aq1M_gwIE^WC$l5)m*& zEpr+?m#h&nAv9IeZ_{@Gj`**(*=wibD|E|Nn8$Kk7@7>-X}ka>r&L_-hiw zr(TG`B1yr8f0;qynOW{Mn<6IdK~}NJHwa%VB>*jA^w8myNJ0pZ)Z-T^kTpe!pN%*b z4(EQezKRqR*W+ZlJx*gZI}1bBT{PBgZ!H%a=R~VurYe3*MTz0%o|qpmv-w7%m)&L# za^s{th1=c%J=}gx|2`!0G?e5GFIiJ7Ll0JZReTK_?Uj!=yDw31SWScjg6!bE_A<7mt4zON*4sk4JVd<1x=c zYjA)Xst*VkiMp$)dmw_R(+!9@y_46_H|l~xRx??^sf4H&Wuq-9EG`;E5U^Ok@^DzM zhR?eDw=LP+`mA}dHrMeRW!6C zhXZHbGj!z~+a!p6g>}H>kd9+6qiIOUb}*{*PbZchd~VVn{bz%lgCKn3D5liTQ@>R# z%wUP<@UxqylvcK?Gs|n7IT@Z>xhdVXASYrOT8&}^g~6i0WAIpV2i7C+2a5ki@1)X| zv-9upu+wkZcR~E6zbAHx^&%o~+8@R4PHy;9dlIJ{__es&(Ry-OZDPjGRfwr#whi(& zf%Gz9pZdvADJo0(?l`I7D!Kq}#Il30E_qS~2U7WDDFNc+I)ENr+!D1GZjN6H7m9k$ z@E}AYb!M0zSl`b6a`|r!Ajnj{Jby_~F75-%>o_9wq{H?G*vihFvGm7!8#9Gr`>_J* zq#AV(30$MWRqC~gEsxO_~O|8SYuNb~M! zvdajVG|_jS4M`fjJ38k^#o2l-^N4;v*th1{_fc7nx@-9Q%b0iFa2|XhZ7`L%_TE82 z^{yh&5B!$AJEABkx0H}yAR7;`u59JCbGA0sVGk@CERFF4j8wmf4zhAGtmC`AFZZiL zBz`d8j6yIPl*;6tI|zF)!0PnPGHs#=)r4KQ5DRSv1`?2OfByUkL%hN>$s3+zmD6BY z<5u(h?w!wR8-ee;Z|UZOsdM7RlWCGbGGKz7R|p^H9p4$gloGFxWU|SaV8ze>L2D8& z1A}*MuXcvRX5T#n{S^5?6iRFtAuW~ASYOyHDp==Eu8wUGiGSB68A$bco_r%!ghI_D z%KMJ)RF(G^t2IsZuY_7<^kRbgSkAip14k6KTE5qDz4y=yFpZnw!qH3wVANMb!}Cfo zd#73@wDx(IYb23({t2_ht zOTT8ch>OwZle926@M15yz#q}oSj>5#qDxVrgttZfdidFCS%8V$F;8{ zn47Pv(!W_Kf|D$9)qWfNt^5P^BjAf9)#iXBW}}|xEDD5U_~mty8&kc-7jdIbp08al z<=;3V9PGj zbYT<)*L!|;%~`a*OmePL_+JiWlHxyEl^!!R46&gqh2q(T>!_1gaL#eIyKhfe5qbOW zPj~MYCwoO@qxX64^}#;nhJAGsWA=WC^eoO8im02JrlnWS;L1zW=gE^Wkr#daZVg0{ z_R`me$xjg@hNTXTeU9H1qb^uwNEw2p=4eys=yXIBkv(j^BTE6UW)S=3{F5Sf)BKK~ zlaabBLGkf>mJ3A6B&Y&*p+%U1Hu&k~zZRU*OZl!6^AXF-yaC$pVXIBxf{EiNBRapP zdsa9jlJugo715C&kA{MIBL0k@;rT~ zs_5TAoEq(^A0;F?$&_Q09ao~*L5jEZ4lW?)K~hsU4U(9lTzUu1&`3OwFSu>-_yNVg2;c43&X-X7l@spWC*4jc2{q* zOVhS)vKgdh+ZW|OHV8jg1Dgu6FMnOT6#L57%vjgnGkNS$U|AVF?{PKZp%P^1^1{kjliK~Murto7HVNP6o93X zMWdoX^B5bI!mJM$!3--o7z9wRt*Bwacm8Oj`cZ3S?CNia%YfeQ-D@vpZ?IkSQ}yYI z!c@Z8j<|5*WI(jNX&>8Ds&KIgM@*)IPS4vU-@tb`1$6PaviblO%JZ|tXk;cOZ8Dj( zuiMaUM8X9yCv4T%m8fx`!uwnE*Ki&K4GO6mbUN>kO6wOdsYv7{$(3n55qe5Yl`wMO z#Z(AF@Q}Wz62qIGT2@o2AcoK{EYfmstIi%{;_8V1A|I#z^+e%IFBMH`Q}6{S-lI%c zGmZW!z1HlP{K8qoio1u27^o zR&3}vy5YcIESirX=8X-9wL4|_w8T-(d#m_0mkfE;43px0SyWizJ)0;9#_nNt?o~x zdwu36d8f~|;Lf)~X`zbRATfH6n$(2ETiGwyS~a03u6N|U_%j(PpPC$=1DPHZ=WA*C z;_Si_5>K3j+?gWEEUQtWwMJxAJGxbDLXhI1K7p00|A>XwL!x|M*3Pkf)jY;-F=4R~ ztbmyZgLRW5QS+*?pg7pye{C!h{8Zea4(KsyJ^Ht7p)O<CQUO~j?jPJWaX z|4ssu+3R(CF5fP#WtfkQAWrUvJn{#DP9%}%C;}k}PKYo7BqFF%L}`{t-sOUwDcDka zfk@6`P6M^03T=Qmp`;GVYP01J)qvp|s;*kOL%xHrR$SNCSJwq+)isyxWaWAUsQGbm zSeJa=#4YpDH4Ja(3x12ptG%}@U9X=<{s<$iYjblUXZq3x#YkmQ#u2EC@lq@0bB^5b zg=|dxq$YSi6H#Q5Pdq^v_urPTvhm*6sMtkw?~A%CjNg7{LLMSub=7EzYG28%f+!dx zq5D+|S;Ec8i=MONKr>p9-(guU@BSa(xe<$xwYrbf(i3qux>n0R$=;E}WQ2bcJ8iTe zmW7qmv-QhkxSgatrT+f5%i&p9*sMw#MfFWZ(b~I_SOMV#?Rr)uXs5(lmj?;S>R4tt z)Q!AG8hcQjgePqH=E@_V9)9dgi|mORX{e|NAx`b|Ogh88)-j%$=jNXT1&xm%(#t14|Js;R1!%|r1gw1)8{Hn?V+do;l?oXY zCl5Utm~)Tx%Kce$XTD#6q(QQV!{q{KDdO4wO)0R>fduj<&h> z+o0fj#^}a{s6trq#O5qHq2w{Vlq9~oKHy+@W5 z0p!{?(0)TjK+}3yaL)b>4=-zU{>bf%-{-Wy*PaJta94T#TWrkuG}W*?cYCd)sN+s= zl-M<^#BSB**0kxkk;qVtqe(5Vf%jqu%@ztMzpUKQfrj1~3JYat?LB_{Gvf7y+v|J} zll{3R4c4_c>yFyC0gJz!eG&ob?@narHy6Ji1Zm$E2Mb6<740@aiHf)KPmM3H#?{@j3H%U_5QI_A; z$UpwDXhj4GHe%+++Yrc_h3qobBqrPpYiH67`D!T&!>=z`wRGzvbVvnLq7h3 zXun#$)d_6)UEZyDy=qAmklSs8hj!PFgY%UsXxE>b+de6pj>yP=(~(m@6I7*fbWEpR zM6e2?LsF=bXB#eqo#lCTZoVFd7|~54OGLTX)~SDd2BfBqkBY{*c|^ixj}p+RfqL)8Z{@|~vad!4`|CcuT0Re{wJg8_-M?YS;0Lg^ zYu?>F5>F=_4(L$4JuDluy*!97OWjhO&KTZ#c1wKTJv}MQ@Q!=VWO#sob@T^R8pWN4 zh;#S=G8B$v34M&rr`1jWw|$%2 zt)E_ru}ylF#yw>yB-jo@gj1yfffuXy@~ZDPypsFF%#NF#<`9c^K>LB@aR~&t<>F}? zn%Y5d$lP{|*?aWZ*++F!jhUC+?MAIlbj2^xH25Ju#)B&`u_G21u$6gp_gsrYG(Y(V zS7XCS?k9MFMvy}6#~n!6d-K@_3nci+LBFKIaX`NoH)XwZa}2pA-S-z3<=4H7ydPSK z={WxA;Qd_L$&U*lDF_EuRl+rjv3YlbMfp_{P2BXJ{Qti`o_p@__!J^y61Y{qe4<8-( z?a3Q0B}1wa12dwb&JcpthwUXoMUH#w4}xr4PhX)+!i-3D$p~FK)}Ee9 zWNLs0qWp70C4J`3pUtlK=>I>bDm->w{))7KJ@80sRhW^T5SwkbrHCuIff)j%YKeN2 z{Z;p@a{N1Rt>s!K2v;MRwNN-YNf-u0gr&6og0cokQM6flq10t+u0u0&$m!CHY#2Z0 zOj}zy@uoH5n=;x1gQJ#-oN%bS*7Tu^2+Rj?m?ub0ELuq(ivgW5X=mGENwLw!bQNU~ul8&~{VqWXMb@eP6}qYk&ilOIG)%Y`e90%|+3qCO#^<3PCq zC!!sVkq{R$cg3g;h~zDOC^R4^Y^p}+~KzyED&Z5&_yVQ@KJ)j7IVV&qb6`tcNC` zDWcM6x$&2@)WPNsjn@I86DCok8*1ww7EV4f*@p>dx2hoLh<{qBS7nIITp(P=#ULD; z+(iX7pK+GXKWBV#$)S!R6YJD`SdNG>y*6%DQ$B)RQ|AR;-F%DlKr9prJrsPLTHSk(2mGNcY%Pws*$1})nYGfL)D7>+&ui?!%9Lz%%Rm`Yq zG_)M2_5v%Q&?-mK;l*U}h?qyy;PE$~B19LaK_cgcq?AJnV(rEaf>4n$CV4)lkFg2T zBNMaAS+xO-@otci4PkFyF>55tlk;m2nZbu43#$rQXat}Q9f2bfLac(7x(7KWC)`aIle1-En^YEKx zu^b;2QXrUFh#xbv9e6^EE4ERjbt?64@vo;SI^%lSLL~;)T*co&ibCG?9h;0EsnK*x zoRqKLclK0MGlzGxh89u0Ws0`7WSfq7UfV^XoCLau6>+9Z=NCQLvvIOv z@hOSHue6%J@=yl-BKY|(_UF`B$-ArPu=GSnnjGM;6Ojz&3RCaxTn7u%S`~M{G?rp zjY=LDOvsA+a#YYpU2mu`^bm7WQ9O*!7*}r5;6_L^Wc5>eS@Rr6!4-|IhC-qX7RWco_hU|H`b zK8T<#(0k=`Q@&TPX+iGJo0_L`Y?-bzymw&&SNmy9vC`3%)PiYcQI=eQ1fFm%I>MUkhT5 zWHwy8^>zF7$X`o;BXcetAKJT$^dhGZ5&{Yy2R8^ZU%oSIjF6IRTK{$EJ-K^d7#Q2S ztgWth12?#|&*{5)ClWl8ru$Bh#$<6MI_l~TQLy|D7Ba*LCV~0p*@}}K+9iW!eo>;% z@jqVo`Ub+UU<9jF*)l#Xcg&t}TiRcbzPb5ra3dn?qUs~NvvGaaTB?=x9HxvP`ux8w z6qb|Jf82t-oh}x{(_GsG;7%o6^rXuPL&S5Q4w`VIOo>8Re-yQ)uOb(nSjFcK$WOOc zo9SjkOldFbAot@&z#b+xCz?unDnNm$y2h7-56XlC!VmyqTl=JwbC8+m!?Fxd_)SBj z{IV^$n|l3j+#tU9eEmhdpQ3~DssTa{C||L}`<~$+P&$TEa@R}mB_r9GgIGDS`(@2t z<0-ADc+-C(ygzJre6yR*}PciCzF8~ zesvER^VeWe-L*Dub2JDpn+f}#6sLWR;CcuxFxs+4UU4%0?9JXun$ylFu*yuiEjR7v zYORa=<%Sx@KoteeA9o{(-P-2Ik0l9zc!s=>02!^Jn&_R$$ed&8v7HD^V+}FEZ?spg z=c)0U|83!<9^c)pZpY$j8~e=4__<>F-nJ&PrvvuKUR%yWm$NQ*Ptw zKm-@b`we_oDK1FK1)211PFnV?D1KRx=*g5?9xzs~(h&=exZxD*DfLMFyKwYpgyEky zFYW#s@*+X-NT#mUU=V2#)xpck2O6rKP)>}>H!L=*<#SZ$dB3{KA(I7G!@bAbw#ej) z#I$1meTAg07B@lCgS4JokTAzQ8jnIt!lvK;V}=^ev>K9AwXMRmN6YJ4|Mh6mBgfXf z4ljM8MaIqGK!xjdH$UD5Rc(vkkxx3Y0BOh%q&z6jD(Tc>9S)BHc^EO})5$p>>_C?~ z<@cSK_u^PJ={5^1cu6^rRY~ZMc}gZ#H6+O`L42z5r}SCR@;?aNY(SlW5Cz9ySLuJ& zB!VfW=LkbMZ*9GVyk15U zUrA9^gQVVCZY?3Q>+NdY9zPIu+?;-i^m*u;0Ai=?MSf*~A*TRP*&d)WP3GgaFlcHY zB3zZof~Q+w99nRT_PCb6_gg5jV0>YTyr34FKgI(1Hq+PGA05tq%0e0&Waz=OjoQHB zgsD-8`~i}1>-43ZzZE$DJq1_@9Xz6+KBWyE)v&n88VZ;MI*lprpy&-kk#`}|c8e;# zm1I1XUnsEAmMBCe3%D(!F!2a8tLx- z#>e~p{>;DO9QHZ0_S#o1F$!h346W5*w$!AeIkBFMoNX7AIUE(QkS&c&1_@h*~jgNkYw4FeuoJ8FCb7Y-PlPAZq1+IxAhhjZqws^72)Dd?+P_a z2+2wK_1rf_vRC#<-tn%8Z}U37F14&@BW;=)8>FRF^}vfdT*z($2tQI9g|*mNxWIVC zd69?>rUhh>@NBI1GF@=ky$HUia@f4$^k=iQ&K`*~_5GF$6y3x{{!kD-Y3O??YriIA zicn9J&7-b&eAUUa#hEE}Mr=x1JAzYbAx(arFB{1RS6RU9LR0wWN5r`9yasVsnr~oYy{>uSJw8LLMro}&b>7f?fH=o zWX!Yl_7yzSf`}NAHN>)~c%s&4pb*P1Rd(yw z>^?}XoFLTK*X284LD3`GtW|7Lx#6nr{N_MG3tf(coX@-?)dTqHE{@3(+!d?XM_(k3 z7&jUn8j8n?<*mWtOyDKCG0wl>V7O6Of`j-92E>23`_pzYQ_bx_GVH^_&3Q0YeB~Eg zCdrQbE3E%D$Gz;q%vI-uZzpdV@}P+22`Z_gtB(aL#$a_3tbES-W_nkZxg@RYjfj*5 zPTm7ZXKPNOfv1845;co4#209M_=%XK>hXB=j9YYa&lbak6()K4a5jI!4>|=EBYoY# zDMrLH;iV*ILUT@F4g)9oDfA@A7R%n}XqhZ;B3$7`e0l0O#lyos%#-7^#aTbyLU02@ZB+7B0@>u39z(_Tk| z0X7Taz%g5rU;0W-wy0^^kQe`U zyr=$GL{AzjH?u`=hQNz7w@We2pNJV6<)xLcd)8(Wj0S1@$>{$*jg02ktG5iv+05Lx z=!5J2da<4^MnEdObSZ1%VKA|!41?^W#c}f2Q~-4V5Gxd@z@*;+QArhmMc*pffZB<| zzr0^jLj~E0EhoDMj<}{2X@gEyB#-6?58D+9t0cfW5SZ%MD^OH^T5!@%sCrfq!qDn& z%E1nb;=t0fMjNUMRPsZ4%}7_-U%x4TuVAjnR#S0F7JWHbtMDF%mC2BXxCA|~6)NAWUPz%kI{)HvV4`;(=ZccyY95Z-3x=^Zj{s!Ul85C$M9!aXAD=ENxyptqsPI zkbF7c(il$5Ry}VrY$rE}W+>^hzM6gYzn?G)52XyICfz?l=BE8i(?Y>1mxgfGg7 z`>U9LpK&NsvXrAq(Azh)*NaLof&n7Dg0g(aUPm!ig3`3X>TrqToU%%&v(RFRRoHvj zfNTj0LPouCD1f|i9WfR*@!Mm?Hzk~8f1h8v370s84ghhv*x+zNf!_u~5iogGdc1`~ zBmTnMs&4a;mXQE$>pGoPMIkbR`(?Jp9_Lv;(m|?sT&tGnh_r$21ARD@CPAngm{WK zLR1Hx>RF_bSNAD=!+nfjjZH5!ZoMOak;81ffn&<^YRRb$=`Ho2-rA#zrRwzk=Pt1d z($|A#v3po{O^|u3!VKw?nMClGK*s3SGPNV}*KDiaLpr|I(=DWw(*kCXsEn#@9dU|?bNF(DZ%9#^>L`$`rh(N94_x!LFIAq)W#t={ zi58b*Zx5uFfwV!BmFyYmt_HkJ7ajZqn$LvHKy{+I)`(F01%sR@Trp%g);Sg#?Q9l* z+qVq+eFC=k2J7w%R&FB0JpmZ#F(0BIr{V2(dQDk#q$#_X-fNCHdi*GJO zGOk2CiR_k}lzck)`;~=JP%qymJf!Owr6vlbwYk8BkSwFtt)f9u_}Q2QFbeK!Nc5Wu zopO%~Xx01m=S=9)CY>(Gf}hrU1!$N)x#a5L`M2UIA^6 zDmqyepL_$-Fxv8PX-rKjC+g2*n*z`FMUl1QW-qme;SZO@m|wK*HRWD7nncgjdYvDd7!E5j@Bw2xS^fSY+ccAVH541q<01c)U6^0Ub#b46vrWRIRue*=EC%yvUrhJa<05JB>yW8a2!izMz9CN$8E^ ztvexGd>|D2_%uajRrF1HY?b9Vk%}fN94iCA9^+NF=z$Q7f;47L?|`_`65(^{?uIFtd=~FILz70Bm|o$SX(EgaVgH0W z?ZFr;0-xF^swdw9j5*&djTN~ppGzz8*;u(lVk;7x*d~lOV}F`dh~sxU?@_@@UuN-c zcm1&y+B%e{8G$|Vr!&S2!%$+DKff(JEY;VA#JT1)t*`tx$MBudL|oT# z&Wc%Sk6e#`N6#w1@qsTu6>T*h>+y;D^9*BVSx-(Er*4YQK|wjZRkA-lP9MSQ}`F$$Q=ewomNBsZy4uowENxL6&m+#Yw>Im~J|-c&-2BPAxq8{QH6!&q zbsc?Az(6HxhpB=`wSiqbh0cYaZFl8Xq6W;cio;1Dn^|&tC`4tF z-2^#bm&c3qZmxKk2TL};5UGMX=LuNVk(dBLtGtom==I8-;X(nx6@ST<-~%}9z@U!N zq@Zh`hqQARBlGuqnT$2T1&H}x;Qf12tvRG@d?I2II=vVS&A#X={PP05ZOUtOEI*vS zz27}?Vb*|}(|2lIkPa207!ha~gjY<0PaY&s&7`jPO*Bp5>d!am>d!rY*LQ4qlg2-T z{DrNRM|3F+2+wVpJNpq~nJ+1q3R{u%wy+by<54B!lZ&R zvWFs(F7ccmIot|R+G9SP8m)>mi7>5IPC_CJ0l?lER3z$Js$6ns?Rl8fSAM6AsYz(*;87 zKoCSMfQ&@Dy!uCVD)T#tB=+=Iea^dVnDchLu~VTo_BY;t?{Sv$^2>guKJy}1h1eTC z!3};98yRFK^j$jJaEx@MJXpb^6l=q0BNayi=R2({ys>S;yPEl?O6^_3%BM+9YL+o! z|C6ViHZA3=%%iZPiF(V`CdCWxH_>sU$CY(XjqMhE+9GV^3u;d3C*iz}k~uGZScxh9 z@%tK_vXrZ7dmwEmSSM~iSF@NbgY0pb_tEMl@^e>C%lfixaIF-sugRgOKZh{CO;teD z#qz?qce)e($9C2lPV($Gr(X9))%e0>nC=xp-g$eb zoT_IX>1j`IkEc@hj&BUcCzz(sh+P&U!DCu-(U6`3jvIP^Sx;McT#E8BL#C_l&j}73 zwk#gYmjrbw{ZvUF<6DTvDyHAP^FU4UGoH~}@zgdmO$Uij-I)>u2|oA}BIFcxU^6<6 z0Zx+7s?owT{nt3s>Sw(|8b?x8^Uq=U_@P(CCkNs^!+=J`Q8ujPbF}D{32G6O`l{L@ zJE_h+>vIsixawZA^iDR#jtcB>bQC^r8G7K}D1S&1vOoO9oqAaz@s3qo!O%@AVXejp z0WcU*Zp||I?)q5&+iQUz)KME1)F?>O@)8=vQyKyhR83pWS-F#!jl=FYrRD$=mW9`8 z)v7?*d!%RMR6~Mjb$0lm<;P`%;_E(tgi0FC>b($8Qae&6c{%cGd{fpQJ5TW>%pYIj zK%%1zpQ?5Y4`N>}E}cH3!>2#N^p!B%zL!d)uyJ{Rq3238jivzoz^SEh$!W7btu@vS zsi5DLf9OYsvTHpB)%j~prI(==osJvkgc~F%x@gm{g!DoGPnpHcDAuQRgy?IY#vMFV z`t?kSk^YiIi1#z9$5Np@sIGHwtNugBUTPjA;HC)vyT1h1T6PiX);^%^TOxR zoKqB`2EyXH}@MDB%sXcXlcvUM?b ze=LGZXH8cmzo`%qq@A4rmTs^%R)nJp*TjbI!}Q6rt|FDAm4p7K>O?hdbhDAr?K9?9 zzO|&M@N7Y#NIa2r;S$beJW%hfwOz;e#DQz$;@F{OImE8|2mF6j?M2_j>zE1OeQp7_kyd{@)>$Slxvk8{3zuh>n{n=X(NPU)-}xbu`AsHlAgV?PRSNJyd*|r zpKl`)%zn7AE-C@$92OKgsFI_*L`RAvyTHeVHU}{?OB$;d1c*|`EbiM&(Ti^^3etqE z_G0B9R)u0Wdb{vw=Pa`)bRxa$V&M07Su{lZwhw4nVtl*ze-KN2r?qJ*7~*idfvBH8xH6p*lEW8; zPXF~P8GH2KdFTcegunU|xcwyQYy^N(4Yg6u-u<<84q*1~;544wxi{^N^b2?sqrCrN zm7HYv6**Nx8nu7HD)hNxYXHVL4h>Rmf0BQO>gax^rGXy*(~ z_VnAN$3j0WOO@p1=^t?acjRFdF%Ha8k@2n~5t?IGsD5V;V-k`D1|^tG(tpyhEkyV; zRR78;8dQGXS|8=gb>W^8Zn0XI&W_o<0!ZZNFe*zjj09F^&{aI zi|?xK$|i>`kbRq&4h?@#ZV&2G*_6j>JW& zj-(}H$bAlOGBmjZ4Xnxp4!xV5OR&WEpCpw{4#dr^Qsk43wX{u(QM0k-NVIQe7X-~* zLHq(2e5{`-;X;@Q;%*rPaYQC*C}d2=KW}|1Y{BI-NW;Gk<}>6DOIX6zs!x@y^(+f$ z<&An#i81WEng&5Mv;0w9&8$g$tbS(@lOsu?3(Wu2TBx&aum=wKbX#I)-&xS~fM7uM zql&;0bYY#W$CyldOJ_^NrIc*!>@p(JN2g`SLm3@#WElluAqH0{o**@K(rECZ-{04w z{YBT&SN5|qrfRdm()aMEH{#=TT9-ex!bl+#y&@Zp2hHcs)L*NWs{?l%5?nwNC?{g{_D){L^cVzJPJabU z3(>p<&fyBSb5!6UKTRh4oH!Y)7MZ=Rl;t+DfL2&!8WM-?V2W0cui(@9VW{ z^xM#ALN8X;tne~3U#EbOV_sZFa`L&5PyjkXe^M-(wzpcS>E<|L;ElT#-rji|b&}K0 z^EnKA44dUUUcSwt{La}-uBrFueCP)S0Uf-#P8&(Cmia3D{*CBN0vK`GAKNEf^a#6{ zpYg!;Zxk$K} z%*GBjthrTEr!_X>uYC~|d(EJzq{beBlkc>0uawIX;2Eg;CRoL9n175ZpqXa@!9SSg z9D}VwZB1%Zrk!ANI}f~ldGR*xPEiOX{lkf;uI(Uu$_|(DXmz|EQSaWR!(qa0a>uOR zYq|yaGjzEnjB=4lz>ElanBZQ%#C$@~n~D^+N?td#M)=?nI#3lu;v+yjq5+qS9>wVd zM^C}<<0-f$y)4VJU%NHFTHvfV9#(H$s=v1PRqq%Fj@7X3^VMx}AcRw+<1EqXQBOFO z5~@pPTG#T1K`pP4mnu=QveqBs@VJcGL=#XDfVmIx+A9n-j^j}665SMLSLSEds_9Wl zQl#1lSa=a+2d!7XE^#)*hmi)re*cLa+v9rmQP-jJ8S?%lPH3%C7@i+!m{W>&jj&iIJ{ z{v`kimo)F|BeI;@BdC`$-SC0xGw=;y`#ElCl$RN&SWwDB^c!s*#gvu3coJHK(f~1& zV9_8Qgi~yQTZCu0khu+1p1P#mIl>W4qF)UXFf?k&GbN;ADlDyy=)sAVUBX7+f2dD5 zg!F4aqH_a^_h`;EZWs>(IaymsrENlva`Dx`Q?Y|wh%^bkmwq-D=VyZ9>#_I=e*}@b zsK6>v)h!J@KH{8@Ir(`$sj7yi^q1CbKhQHx5o~AEiP#gF%ezrSf5{d|n^*+#ctA>5 z*~l-u1yn27LDI2X&AY&9PM(0Y|LMDme_&#n6VXQS z<@wR^zDPM{hkr>S&V(jXx62w<1rZ5eop3x-z7H-2rYIn?#ZruyY`U5fj=blV2mfK) z#sS6CFQ8SNsq|qMLECGs($fU3bO*`L4K{uaAXQX)c-y6Ynb^15&LL<3UmqWG8>2Oe zve3$>tOjYqM@;rM%Vxg47x;)(h9zsh->9&O?PO$qgWt}y^zPowC}KJP}s(i$h5^^|?Aw9Hy$QcIKVB$VeaXBzIn=j@dGK zi36@d2Z{H0JgjRBrg%`TcXoB6)3V_#GA*9T=F*{o&C`Rw{qDVr)}xQ(?aYwKiknx~ zkNqzQS*8l)fVgKWkMJ18<+1PGe+w7bPz$|b9QS(uUW(LhiFA0X3*$@oC*zu_rb|Os zYptTpbi#1bik)!^0NWE1i}}AMDh_ut{``jyj{t!PAWZf4kG>~5`*jI<%EamH6+6~A zy8^8TxBQKV=Wld6sIOkv_(eA=gR~rh!+5W@0Bhu&k&lI0WUWAq9U#z^1TLd!L|tqd z9hvumbG1XU3(ZGiPMw^B%odlmhTo(s|>3zm6d!+)vS!p-p2}Mu0?R{D4p0jcoF`>u7wB~Zz^Hv1-qGT9(`;Wb zRYRyzFird#o?V481)$3{EKR}U6D!W|z0lVC&UWxZr@a#!M4t&_!>IY_S$fQp_sKTt zu=(DU!PQMFvYp8;eAOQrn4Rp>?yYKlo}bbNAW*Ctmtha#w;ZEiVaii1!dxQA-;W{2 z#ZtvtH&*Ki=`_{{tNbm;fQB%j;$05e8T~&!&n$ctAz8N3wVUUQZ?pN)O%(_Wv)RLb zBRof^vHT_9NkXbcJ^E@G3=o|xe$U$}vI(sE zo)}LtKPl4?1#(@1Oc{p5YOT}ylsa?LajW(`gOq^JCo*ER%_)c?(RyBgAil>4(xeor zL3HsksEN0qT<*gnmoh zdkR5bh(>|GAAFe-C4NRsova$UK5H#C5}~5R14(<((xZ_USPgQ#ta0S(LZCxX2#dm= zy3`M>7ny4+-4Vzk=O^g zshU8>uQU4{Aa+eAfJm~&VnKQE{jtwNjW1A2VDS4OelhLG3jvU&tq24+b2`Lm`_3S- zMDCQ9cQeVGeV0OcMlfZXjDGKS(BJ3L*xPe9zd4TQhkJaD!@c@p#urBbSN~gh=A3to z%^aWz1KDn5GokoeBJfx8ONH0IsMONQw z$Yh~SI4d4mfwnFXS!mEebfAvZU5%A^z$2k5oY&&3;4wa-dlA>;7XstYpE9Jo9)Ie( zt5I@)DV5nkWEco;*KjqfHms2C|20-rcYZ+@-!^v5!tP%}pb1pGnQa2euFJ?SSt`JG zZ(!UayK|WX%W(s;5Z;oS*K=Q(u15cfPkPyNHgsw%LhLplJlP3;8E#%kEn!&XDFT1j zATozdrgU}@mIeh`LquFR=6`GFEM-mj2QUk2vmG_6$ zC&V-=7=B9M(;9~qStWOE8axafQwH1E3Lm0e#`d2~N+FT7C*_DW5{GOo2shM%DabXd`bjsqn+5ftE9Vu5ag}dIedj4ylf?? z3D{4rXkWmjh*(`aP11wv)CdZav;-)|sGUj8hdL!eDq0FMU{F~*#BwUH2EmcHDThjD zgy)(MYZ2-E&4Qb+?zdmLFQ*bFs3uU~{nu>+5>#3)+Ex~ah?7`NAQavScPAk$v()2I z{l}QGNcA~vtey9ZBYuLMTO;(>Us%2W`l*Q&ecmAi#;JLmS$(Q<52M6Rrd8IkLz%rg zKX4PQCdg*_IR;p}Cs7Z;n0esna7s~WQZ-S2IOFB;5DTgv{4j1&a1@Z-Zl}CRKw)yk zfvo3-PJ5ET>$!q8Q8}}9rm`cueeo9DwEOdugKfwRt>yFNUarxB)ny%+?MpPcT$w5p z_#)sdR_?7%0Dr6pb!Su-Yjhi+{P-os+gl}qJ>7MC6iTVvbg=tM@o=fEAI4csv{aaA(|zZT_!d_$%IjHCII4rba#T)mFEC1)tEcS; z>D2?Rks*W%1Ek+Htnt)2OuQ&$ix4W@7K%=5!xq5+yyWfmJO_a0AmFv#^*lsKUX-~3 zovy%pj&zI5`0_!hEiaSeZs$M17-}F@qB@LK0*_ezM^-W?)HwGuA8 zRR`tfhxeGZ5bKjQI|4#23;DK3s{&eqS2CNVeTk^edUF|Fa!v%L)=`ynnThYpY6fca z4H4)3sA}aiMFOOM4 zi-$^bw8*cCYiMti2mN@(AT;7w3!JF0$5dm2K)_FADId1RQE!`exBd+5N+#86bjXX} zKbEYc2kqn#{o8~j-)BUnYNS?>#oXp!i=OO(W2p<%W^=&mjrw@)g+wgn$v(n~y!~WY zwCpPDlG+7MrXG)s#CY}^P)1&L)?Sw^9_1D)c(PIz`VoVfgQh6`l-s0d_RqfcUkT22 zC%-xYmMufq49tEygbu5jiGjMf2wm)sT9^Rvgm;FHh zEjXLmcuS*+rneFUu8wgNM!vrfyGq(@e~oPbhNJA9p()! zH@Z~;@S+Jkw+vBi4YluF1*>_ro!b1~%N!lu#CvAEB8j&@Slx(G^HsK(HQeNauRDv{ z(=_#_EKdDz|2C}hd%+!_2@ISRYxx_v98SCAXZ9mXr z@T%Cc9zc%m81-78d_l7dPWvTrxX-(e;?2=pzm#|EjUXXN*JiB)6tfm&A5T~hhFWuQ z8lSK9--!(JBgz|aE01|6gjxjmDeY!ui;_2=cA9E`uT2!yWQ&pvx*3l<(A+T3fQCRf z8Ojjm$8Fu=Pqv#FgYZfqngH%+%|J!9cufcmbt;}pdxK}~ssx9r|2DjGwr-5=`S0Z% zc<&Xj#Mk3yOHE@!sz2_L2=(Z|g;bGu_o2bo{nhil}`$-^vx1_m0anr|glC!Hbi;144>3&De zZ!uV&7x?aU`E8TV9=H<4HMP!b#FCQJydwOv@HWbqFD_%HnrrR*Y<11;{!5%g35;Ke@VR3@l9DV^+%EP*gkCBL4aw)PVES za!++*luP`X#x0w3{k5)1=A=IRc~EIj=Ohe>VkR&3+|h+3eqC_3!0RE3bNp1h!4vxG zl!ypRZxP+5IGj_%Uu$NSOVZNAAgx@P;Dy`f+8+Q}YvCXQh;Zpa){*((UC9h566BZ9 zk%X7pX9aj;ht?YDL z=o47o?UKeoP{#X~IRAV`>?M4?S!G4b-7 z7}mtYPtPt}w?i)RbogP_L| zjo~cMf^*)e6ziGo^4686DL5#4qC;3K5||LGwW*_D&N^NW+Su^rn}ExsV0l{0DEllq|*PaaV;@mbbI>z8X(JC9;9Qa?kK(aq7`Y z{>vtYh%6PtMtOTi*-kFP*0BTna=@1#B~>*G0#0Tm%~ydGdS)}eSe!c-+9#YjEu)-d zbj(JR%GcUZgZUcnU`ClOtxqwJ$l#Jt%3UU8AIiJw6pU&u|6*9XCD*;OIlQhyxxktv6#1gKF3-bx`;KyQ<_mfia6q6U5w<*VIz3yyHl)Bwh zXTLvu=-Fp~;-L*LiE7KrAXNeF@Khs`T(*iDcvQOZ8lZqj)>R1=wrnAGAtar}2|`w& zS=dpi=zP!2p|og4X2~!qqfY=(p?5Mm*zxBy4u1M?NDNw;Ojb$YN9vr*dk+3XG@s&q z7Wgm8cGM!sNyVxKyytjir3fei%7bNhn>G@T^8vSej;BFp8ks!gZ8x}hL=X!anQu1B zSR$4tHkAoX|9kEuH|2t-dYe(Hw(2?+7*9Q%c#0CUO>!iToc93-KR-RNr{KpU(1tW^ z<-C7<{Y9VZfAH8d>Su$9Q04)MO({l>Y(ZN_Ai>rUlixkR;g_%sx?$pC1MrAn)h&I> z;1UZaLiz*`!Uwop)Nab8zz#DIbA4tz9sv1Z134zteyV#b%T;jw##uHiOm=%-=u$J< zK&qoqgvxahx{O^ocxo|C)_-y+u&$j6;*W)u0!sdPao}@eERYSD7-F^}ZP$e9U(B80LjY@aS%DJ)g zHLsN5-may;^zH+3iQ73M9mN=s_`7P>*Tg#p!xQ2W%`&0G1E=rvjEXe=XQTX643fR= z?u&Rl^nCMM^X^2zOp*qKL95!bD%CiLXrp3`mB7=j`drW98 z{HL)oYY;Ctf{{bq_)B>7_lm9La+Gj5UHnH3KjTS+W3C5{N`~Oug974%vlOQLuD*3o zO5q?1NkG|3+A!@sl-$(}W1(;~Mu;Nw$DhOGc*>@T znRllXaqUm9w{C=KD{C>7QL_i8-N?Mq!w$mK8Qyi)z<81VT+(x9%woq%po{9AK1i-Z zVK64As~zI|AX$Z-b=@uQv7egdC&XPwi>Sd4mm9 zi6|sppB7752feLMIQcoYd9pEv#c;#QSZsg=k=(DkHI~lzWw4e5Ff4d}9mSmp?iAZR zIWk_n(HN_yK{X5Sa@K8iD@u9Z;GQ=+^JHVVue+NZHFDjqnQ?T%T*$oeu#qMNR=j?a zLC|^!AqBvTzh-tP-PKU(pVakyx@AS@jhIr{Q8*y24Xq6Z5$MWkaAyg+b0({`hh8|W z^5t;P5tD60hyAnLB5KiW4~dMr_-)wwIK`$^=;45(C?D-K_U`JvtSO1Jz3Ll z&u_036DsbxZCS5l?z&(6VE z`>ml|M#XFu5E(q;Ue!M7t3<;ZCEY$Ji3(fhtHDRw9GoB1wBvng-ea-_aZOJSb%H3k zJZxb<5=m_s7X-*B(qZAY+EePJ2h`5;D~QF#VXyy$SkW(B;}iC95wA+M=jwAbUz)me z4(N(RUpF7hIT|EI97fJ+sS&&Y6+;`VX-6|bor)Wl#fULZ#GNJ%vMTBlS@FFJ^pA^? z94vMKd5xoYaar|7Vsr0^jT{Lpo)WsG#GofOShCVNMZ^hWA+{Wnsit#=68D}Za^)rw zn;CJ6tr$!*i(HR9drsc$u~M;V<}4{OmGq02?bM$59$8UVDOzWzEglj~Ma>InLA$L! zdNadKcVe7q?1*G^*%MxR@q+twVU{Ci`QJFDrf)e>LmZ?pbr>;CbVZYi|rvIdY<){-1qr)?;8kbqcY^iEpOgo;{jk~jGEw+}NF+vr3 zL-=8wvcDHs)gX<79Bb)7AoHrxpySH8^0KLP;rrG3!T!tG0BXMW?VZ<#`a|t{Ae%IC zy^7Lep`)?Tw#eq_(4Vk`KNUO+FV?%RwU=tZy&m}}<(#IifR$wYuRztZg*(Da<=*1<3U1t+4XK-A>)QXm&%3y?aHqo(-0q;kwAjlvM$ zHe=xep%QOD3x1+g4LRCn$N!=C_Q$fKURlLmO%`w9$mEgTZ$1egVy@2t1}Ax7IiXQt zNRj{gWY%_nHq#$p?pr1r2Aj_HA0BoLM@}7urv_(F3F|PgKmUCcy}-+Po1qZsW8LIp zsIpY<;NKV-Y)+kP-s5D6Q_iRxL)Ek-jGHjwo zPqD#iB!J*XROIUtwwqypldEhk?~(D%!$izLtIWYpDy@Z2VvEk2JrpVSX~rrg^&OpP0WVaHASlu z#BxYFJj+KW@?;IUkKI8WlS(f?NLZdaoHRXNG2x1toOqW16goRq&v2*_5!>R&O=cqh zA@(t3e`Hgw>XCn!k_=CnD)}+XTUMq)6)F-HWKC6=xX6|+&25`gVuz>`)O0YU}K59m?iZ>CzYB zvS9SCcN*=_UY#nUi9^E$fuYO$p6%0nr4Sdk3q&%3FV5}|vVgNTE9u{a;hez34XOIn zL)gOJ=cSbK*byn~(++x${4j@_XsYEz5|r+4tD z@z$lw-1~n}2M^T!G=BnZ;A!tfi7Q<<^2t+fOzj_q39?Pn5X!Z*jyh$HM* z-4L`MNVIb=c;qS2+s;m~AypmmHQNlSJ0j+FwqXYxVk0C!@ zl&BDx968y0#a7b0|d}dx}&%VK*BRC=dJE&!(Sv_WiMmj-%hmOjoDGapeox`^>?f;>g zti){o;g;f_d0fm~Je?UxHDr%Lv)&~@F#z^*#|!^G4snk$b&+oiv+RY%+26LiQmyGB zPzB6kDo-Oa*zx_z8$NI7IFNp*-6Hv>$&I#Bx1iIwbM@31CH8nw|NQq3-<}?9QyNy=Wo|~|9ltL9x*kO`nG~HARgg7IJ`){2QvwCE(bSKxNIJwpkGqT{i7ergVqt41}1h};Hc=c zIPn3$ZaI)OQYgONl$v554gEbsA$e=#Bi$9rOo)cc+Hx@}ueUwf)pk9`ti7%m^fqt>3;JlU4`8;u=1rmRC zJ_djg9aft@COg;FHe_JLUMOHokUMO8awsA)>oRpuCm|VG6~xa4R2XXa*^oe1#zyqR z5#+s8)AD8}bG@sbiFVqkHtp-u*>90sgDX6+wZ9~ulH}^a|L<gqiZ?H3)^rMF(fJ>vBI8Ptl!dl!ys0FEl9L&xnZQl z{+O_GiZW14gWz#w^bEO;mWL}4uJYSwIa{x18)AZfIY+H8S{CtXu8tzTR!UH=+-=t& zwDzW^=R*tB-s7OPV+jJ{k^`1r=5bKh7YGb`Yys+`V6iZOhpk-s^7 zhKfe?&5FqRIa*J)YlRmp5uitl4QY4%xGr?tNi4s&JJ$yO0xRz%Bb0SO0_AnM-RMWhMOpIQ4QTgf2&QDg#5;Q3f}+GX0-rgYVS}Y&_pT^QLOJbg}V0Q z{kGijnlP>Cg4T@woa{qIgwRIdJy8e@Nmf(;_!!m7`l{l==VAk4-o=>1W0pz9OBO_} zTr>GGwA=nmsf5I%C&6-%YCa{QXGMJMQT`=o%DVZbYKMvlWktn!5EIG+4>k;fem*zi zG8i`kAu*b?vNowxk}BoploVXJs%aM%T*5WkW)GY|j>LhbNNJoxbaWMmbKCY5xc~cC z($thJ(evJoN0!XlBN)r`_TZVH-dHe^{_rweNr8fg{er~W3b9+MrBG$z(;rqfxb1jj z$jg*o(qnhjB47PFmAeBU0hd^$M1N%+86r|vDYqfd<2*p7Gi+=j{x@g?vR)i!kL@iD zUkh#%HP5j!BFSv55aSZX8(b>p-L?44bw5CM;qp)o-2EFh&qgNRGf3T(-n~qAk>;|b z3&@B=)>vMJl3JT8=ROD|a{9fyR2k6wpDq(GoTt%>(E-{^3>lX2ix!!1u^5b}TJLhW zi6|8_BgC(l^3=*$4}lAit^fQlpSo&SzA$E=@O{YUHAJBu1sQWtBbVS!zN;SOxb_c_ zMC~PFDVF^h86^%)%(-(km+2RuoUmvg%GMEmlzAR#Nr{Mo9 z;*i_dg>BS8WgZWDCvuQNMfS(1KGibF#3Kl<61QYc7GruBRA&l#EusxQg`Z%E(1JNB zA!_>)jey3rFe_OKZ1Ti+vuo&(TV@?L+pew>$NKko(_X}auy(RhQPpVv9=sA3QlK~% z;h7D_OJt$-QJxqJ6Pk!Br=7Cr<=|#%Wk*BQjg-Q^cAWc~1K3eJ<_Q;`C{vL2GMd*e z5yDN-Q=8ZSJCYT9Z*r2?e}GiHk@tBBp~(e$x=LDl16CYJGe2xomC~#i3?{s=NWLQF zLbrs``E){P`mN)R^;V|SEGV{Zo(TXRTi{ko5!OGCP4l z+?rnOjELM3XkD#yK_}kZ@{TRE<}oHX>TUc-^Vv)54adLA3%LT1P2sk_p6~5v<>TS3n!H5Xv(0YT_jXsUsV6mW|sxjiD9X2$=d_#4s<*-&!!m zLiWuohz8Z|ie$fqdF(&oG~|67gl;k&n)J-2?=@UH%h3(W2-YWi#)pGJIlWZHAna@= z!b($%2RM<~xeK#ZbjgeFC2IYG45jGiAzE0IrE^U7UxMULw*2_ZYD&rY;+}D{0oo

{^Fs1wC!ys~qb#haBPfuxcrj$rVVBk_t#x* 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 NE2St|D{dJ4{{R5buN43Q diff --git a/x-pack/plugins/observability/public/pages/landing/index.tsx b/x-pack/plugins/observability/public/pages/landing/index.tsx index b6140956412508..512f4428d9bf2e 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` )} /> From 69ff09ec387e73d48732b7e057a8703df282f45f Mon Sep 17 00:00:00 2001 From: Sandra Gonzales Date: Thu, 9 Jul 2020 14:55:48 -0400 Subject: [PATCH 13/49] Revert "[Ingest Manager ] prepend kibana asset ids with package name (#70502)" (#71271) This reverts commit 984ea0700ee8b84e69f626792e1dd913607307c9. --- .../services/epm/kibana/assets/install.ts | 119 ---------------- .../tests/__snapshots__/install.test.ts.snap | 133 ------------------ .../epm/kibana/assets/tests/dashboard.json | 129 ----------------- .../epm/kibana/assets/tests/install.test.ts | 35 ----- .../services/epm/packages/get_objects.ts | 32 +++++ .../server/services/epm/packages/index.ts | 2 +- .../server/services/epm/packages/install.ts | 58 +++++++- 7 files changed, 89 insertions(+), 419 deletions(-) delete mode 100644 x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/install.ts delete mode 100644 x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/tests/__snapshots__/install.test.ts.snap delete mode 100644 x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/tests/dashboard.json delete mode 100644 x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/tests/install.test.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/packages/get_objects.ts 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 ae6493d4716e81..00000000000000 --- 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 638ed4b6118c99..00000000000000 --- 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 e28a61ae5e18c3..00000000000000 --- 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 f9bc4cdbf203fd..00000000000000 --- 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 00000000000000..b623295c5e0604 --- /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 57c4f77432455d..4bb803dfaf9127 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 8f73bc9a027653..910283549abdfc 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; +} From 1b4804b986ba254ef5a73dbd10cfff490adb9c23 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Thu, 9 Jul 2020 20:57:05 +0200 Subject: [PATCH 14/49] [Uptime] Jest test adjust to use relative date (#70411) Co-authored-by: Elastic Machine --- .../__snapshots__/monitor_list.test.tsx.snap | 4 +- .../__tests__/monitor_list.test.tsx | 40 ++++++++++++------- 2 files changed, 28 insertions(+), 16 deletions(-) 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 126b2eb9dfdf65..80726fd1ce7eec 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 @@ -1132,7 +1132,7 @@ exports[`MonitorList component renders the monitor list 1`] = `

@@ -1309,7 +1309,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 3bba1e9e894c6d..2cd246bf7d5b6c 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'; const testFooPings: Ping[] = [ makePing({ @@ -96,11 +97,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()); @@ -113,20 +128,14 @@ describe.skip('MonitorList component', () => { setItem: jest.fn(), }; - // @ts-ignore 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( @@ -157,7 +166,10 @@ describe.skip('MonitorList component', () => { it('renders the monitor list', () => { const component = renderWithRouter( @@ -169,7 +181,7 @@ describe.skip('MonitorList component', () => { it('renders error list', () => { const component = shallowWithRouter( @@ -181,7 +193,7 @@ describe.skip('MonitorList component', () => { it('renders loading state', () => { const component = shallowWithRouter( From 9bfdb1c523fbd95fbeeec34bcb6215e354f7fd9d Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Thu, 9 Jul 2020 15:25:44 -0400 Subject: [PATCH 15/49] [Uptime] Availability alert (#70284) * Add new fields to monitor status alert for availability. * Add UI code for availability threshold expression + translations/a11y fields. * Add availability selection to alert UI. * Disable expression popover button functionality when not enabled. * Add select box for monitor status alert, reorganize layout. * Add new runtime types for parsing in alert executor. * Add new enablement field for monitor status checks. * Add availability check query function and tests. Extract helper function from similar test file to generic file. * Add availability checking to status check alert type. * Change availability threshold to be number type. * Remove clearing of fields when disabled. * Change alert validation to require availability or status check. * Fix threshold input functionality. * Add tests and refine alert validation. * Add test for new validation logic. * Add any type temporarily. * Delete unused code, fix types. * Add filter capabilities to availability type. * Disable availability by default for old alerts. * Add filtering to availability query. * Clean up types and refresh test snapshots. * Change threshold storage value to string. Add bucket selector agg. * Update copy. * Add tests and improve should check flag evaluation. * Improve old alert detection code. * Fix issue with status check enablement. * Update unit tests to reflect changes to query. * Fix types. * Improve tests, refactor a function to clean up code. * Remove fields from aggregate key and retrieve them from top hits instead. * Add sort parameter to top_hits aggregation. * Update context message of monitor status alert, and add translations for availability message. * Modify default alert message. * Add a comment. * Fix outdated translations. * Revert unknown to any to simplify validation. * Improve readability of array manipulation for availability result description. * Add a flex item wrapper to fix layout problem. --- .../translations/translations/ja-JP.json | 2 +- .../translations/translations/zh-CN.json | 2 +- .../runtime_types/alerts/status_check.ts | 40 + .../__tests__/alert_monitor_status.test.tsx | 49 +- .../__tests__/old_alert_callout.test.tsx | 2 +- .../alerts/alert_expression_popover.tsx | 18 +- .../overview/alerts/alert_monitor_status.tsx | 51 +- .../alert_monitor_status.tsx | 10 +- .../down_number_select.test.tsx.snap | 6 +- .../time_expression_select.test.tsx.snap | 101 +-- .../availability_expression_select.tsx | 178 ++++ .../down_number_select.tsx | 3 + .../alerts/monitor_expressions/index.ts | 1 + .../status_expression_select.tsx | 58 ++ .../time_expression_select.tsx | 51 +- .../time_unit_selectable.tsx | 52 ++ .../monitor_expressions/translations.ts | 12 + .../overview/alerts/old_alert_call_out.tsx | 2 +- .../overview/alerts/translations.ts | 119 +++ .../__tests__/monitor_status.test.ts | 81 +- .../public/lib/alert_types/monitor_status.tsx | 25 +- .../public/lib/alert_types/translations.ts | 3 +- .../lib/alerts/__tests__/status_check.test.ts | 581 +++++++++++- .../uptime/server/lib/alerts/status_check.ts | 255 ++++-- .../get_monitor_availability.test.ts | 853 ++++++++++++++++++ .../__tests__/get_monitor_status.test.ts | 99 +- .../server/lib/requests/__tests__/helper.ts | 49 + .../lib/requests/get_monitor_availability.ts | 160 ++++ .../server/lib/requests/get_monitor_status.ts | 2 +- .../uptime/server/lib/requests/index.ts | 1 + .../server/lib/requests/uptime_requests.ts | 3 + 31 files changed, 2583 insertions(+), 286 deletions(-) create mode 100644 x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/availability_expression_select.tsx create mode 100644 x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/status_expression_select.tsx create mode 100644 x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/time_unit_selectable.tsx create mode 100644 x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_availability.test.ts create mode 100644 x-pack/plugins/uptime/server/lib/requests/__tests__/helper.ts create mode 100644 x-pack/plugins/uptime/server/lib/requests/get_monitor_availability.ts diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index dee92c4fbad583..92285d8bf72f84 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -16355,7 +16355,7 @@ "xpack.uptime.alerts.monitorStatus.addFilter.tag": "タグ", "xpack.uptime.alerts.monitorStatus.addFilter.type": "タイプ", "xpack.uptime.alerts.monitorStatus.clientName": "稼働状況の監視ステータス", - "xpack.uptime.alerts.monitorStatus.defaultActionMessage": "{contextMessage}\n前回トリガー日時:{lastTriggered}\n{downMonitors}", + "xpack.uptime.alerts.monitorStatus.defaultActionMessage": "{contextMessage}\n前回トリガー日時:{lastTriggered}\n", "xpack.uptime.alerts.monitorStatus.filterBar.ariaLabel": "監視状態アラートのフィルター基準を許可するインプット", "xpack.uptime.alerts.monitorStatus.filters.anyLocation": "任意の場所", "xpack.uptime.alerts.monitorStatus.filters.anyPort": "任意のポート", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index ad3c699db03c82..457f65e89083d1 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -16362,7 +16362,7 @@ "xpack.uptime.alerts.monitorStatus.addFilter.tag": "标记", "xpack.uptime.alerts.monitorStatus.addFilter.type": "类型", "xpack.uptime.alerts.monitorStatus.clientName": "运行时间监测状态", - "xpack.uptime.alerts.monitorStatus.defaultActionMessage": "{contextMessage}\n上次触发时间:{lastTriggered}\n{downMonitors}", + "xpack.uptime.alerts.monitorStatus.defaultActionMessage": "{contextMessage}\n上次触发时间:{lastTriggered}\n", "xpack.uptime.alerts.monitorStatus.filterBar.ariaLabel": "允许对监测状态告警使用筛选条件的输入", "xpack.uptime.alerts.monitorStatus.filters.anyLocation": "任意位置", "xpack.uptime.alerts.monitorStatus.filters.anyPort": "任意端口", diff --git a/x-pack/plugins/uptime/common/runtime_types/alerts/status_check.ts b/x-pack/plugins/uptime/common/runtime_types/alerts/status_check.ts index 74d53372566016..5a355dc576c0aa 100644 --- a/x-pack/plugins/uptime/common/runtime_types/alerts/status_check.ts +++ b/x-pack/plugins/uptime/common/runtime_types/alerts/status_check.ts @@ -24,6 +24,7 @@ export const AtomicStatusCheckParamsType = t.intersection([ t.partial({ search: t.string, filters: StatusCheckFiltersType, + shouldCheckStatus: t.boolean, }), ]); @@ -32,6 +33,7 @@ export type AtomicStatusCheckParams = t.TypeOf; + export type StatusCheckParams = t.TypeOf; + +export const GetMonitorAvailabilityParamsType = t.intersection([ + t.type({ + range: t.number, + rangeUnit: RangeUnitType, + threshold: t.string, + }), + t.partial({ + filters: t.string, + }), +]); + +export type GetMonitorAvailabilityParams = t.TypeOf; + +export const MonitorAvailabilityType = t.intersection([ + t.type({ + availability: GetMonitorAvailabilityParamsType, + shouldCheckAvailability: t.boolean, + }), + t.partial({ + filters: StatusCheckFiltersType, + search: t.string, + }), +]); + +export type MonitorAvailability = t.Type; diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/__tests__/alert_monitor_status.test.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/__tests__/alert_monitor_status.test.tsx index b955667ea74008..f3f3d583fd9382 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/__tests__/alert_monitor_status.test.tsx +++ b/x-pack/plugins/uptime/public/components/overview/alerts/__tests__/alert_monitor_status.test.tsx @@ -59,21 +59,9 @@ describe('alert monitor status component', () => { - - - - { setAlertParams={[MockFunction]} shouldUpdateUrl={false} /> - + - + { size="s" title={ diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/alert_expression_popover.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/alert_expression_popover.tsx index 00e8e451489855..0ae8c3a93da949 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/alert_expression_popover.tsx +++ b/x-pack/plugins/uptime/public/components/overview/alerts/alert_expression_popover.tsx @@ -12,15 +12,29 @@ interface AlertExpressionPopoverProps { content: React.ReactElement; description: string; 'data-test-subj': string; + isEnabled?: boolean; id: string; + isInvalid?: boolean; value: string; } +const getColor = ( + isOpen: boolean, + isEnabled?: boolean, + isInvalid?: boolean +): 'primary' | 'secondary' | 'subdued' | 'danger' => { + if (isInvalid === true) return 'danger'; + if (isEnabled === false) return 'subdued'; + return isOpen ? 'primary' : 'secondary'; +}; + export const AlertExpressionPopover: React.FC = ({ 'aria-label': ariaLabel, content, 'data-test-subj': dataTestSubj, description, + isEnabled, + isInvalid, id, value, }) => { @@ -32,11 +46,11 @@ export const AlertExpressionPopover: React.FC = ({ button={ setIsOpen(!isOpen)} + onClick={isEnabled ? () => setIsOpen(!isOpen) : undefined} value={value} /> } diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/alert_monitor_status.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/alert_monitor_status.tsx index a1b4762627e7c3..b06b45f6fc9e78 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/alert_monitor_status.tsx +++ b/x-pack/plugins/uptime/public/components/overview/alerts/alert_monitor_status.tsx @@ -5,17 +5,14 @@ */ import React, { useState } from 'react'; -import { EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { EuiCallOut, EuiSpacer, EuiHorizontalRule } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { DataPublicPluginSetup } from 'src/plugins/data/public'; import * as labels from './translations'; -import { - DownNoExpressionSelect, - TimeExpressionSelect, - FiltersExpressionSelectContainer, -} from './monitor_expressions'; +import { FiltersExpressionSelectContainer, StatusExpressionSelect } from './monitor_expressions'; import { AddFilterButton } from './add_filter_btn'; import { OldAlertCallOut } from './old_alert_call_out'; +import { AvailabilityExpressionSelect } from './monitor_expressions/availability_expression_select'; import { KueryBar } from '..'; export interface AlertMonitorStatusProps { @@ -69,22 +66,14 @@ export const AlertMonitorStatusComponent: React.FC = (p - - - - - { + setNewFilters([...newFilters, newFilter]); + }} /> - - = (p shouldUpdateUrl={shouldUpdateUrl} /> - + - { - setNewFilters([...newFilters, newFilter]); - }} + - + + + + + = ({ }, [dispatch, esFilters]); const isOldAlert = React.useMemo( - () => !isRight(AtomicStatusCheckParamsType.decode(alertParams)), + () => + Object.entries(alertParams).length > 0 && + !isRight(AtomicStatusCheckParamsType.decode(alertParams)) && + !isRight(GetMonitorAvailabilityParamsType.decode(alertParams)), [alertParams] ); useEffect(() => { diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/__tests__/__snapshots__/down_number_select.test.tsx.snap b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/__tests__/__snapshots__/down_number_select.test.tsx.snap index b761bc3e2368aa..bf56ebd0de2365 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/__tests__/__snapshots__/down_number_select.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/__tests__/__snapshots__/down_number_select.test.tsx.snap @@ -8,9 +8,9 @@ exports[`DownNoExpressionSelect component should renders against props 1`] = `
- +
`; diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/__tests__/__snapshots__/time_expression_select.test.tsx.snap b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/__tests__/__snapshots__/time_expression_select.test.tsx.snap index cbbaccbab34e4e..487d42cfdb6f2f 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/__tests__/__snapshots__/time_expression_select.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/__tests__/__snapshots__/time_expression_select.test.tsx.snap @@ -14,9 +14,9 @@ exports[`TimeExpressionSelect component should renders against props 1`] = `
- +
@@ -44,9 +44,9 @@ exports[`TimeExpressionSelect component should renders against props 1`] = `
- +
@@ -93,62 +93,41 @@ exports[`TimeExpressionSelect component should shallow renders against props 1`] - -
- -
-
- - [Function] - - + "aria-label": "\\"Seconds\\" time range select item", + "data-test-subj": "xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.secondsOption", + "key": "s", + "label": "seconds", + }, + Object { + "aria-label": "\\"Minutes\\" time range select item", + "checked": "on", + "data-test-subj": "xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.minutesOption", + "key": "m", + "label": "minutes", + }, + Object { + "aria-label": "\\"Hours\\" time range select item", + "data-test-subj": "xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.hoursOption", + "key": "h", + "label": "hours", + }, + Object { + "aria-label": "\\"Days\\" time range select item", + "data-test-subj": "xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.daysOption", + "key": "d", + "label": "days", + }, + ] + } + /> } data-test-subj="xpack.uptime.alerts.monitorStatus.timerangeUnitExpression" description="" diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/availability_expression_select.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/availability_expression_select.tsx new file mode 100644 index 00000000000000..58a6bd910d669a --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/availability_expression_select.tsx @@ -0,0 +1,178 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiCheckbox, EuiFlexGroup, EuiFlexItem, EuiFieldText } from '@elastic/eui'; +import React, { useState, useEffect } from 'react'; +import { AlertExpressionPopover } from '../alert_expression_popover'; +import * as labels from '../translations'; +import { AlertFieldNumber } from '../alert_field_number'; +import { TimeRangeOption, TimeUnitSelectable } from './time_unit_selectable'; + +interface Props { + alertParams: { [param: string]: any }; + isOldAlert: boolean; + setAlertParams: (key: string, value: any) => void; +} + +const TimeRangeOptions: TimeRangeOption[] = [ + { + 'aria-label': labels.DAYS_TIME_RANGE, + 'data-test-subj': 'xpack.uptime.alerts.monitorStatus.availability.timerangeUnit.daysOption', + key: 'd', + label: labels.DAYS, + }, + { + 'aria-label': labels.WEEKS_TIME_RANGE, + 'data-test-subj': 'xpack.uptime.alerts.monitorStatus.availability.timerangeUnit.weeksOption', + key: 'w', + label: labels.WEEKS, + }, + { + 'aria-label': labels.MONTHS_TIME_RANGE, + 'data-test-subj': 'xpack.uptime.alerts.monitorStatus.availability.timerangeUnit.monthsOption', + key: 'M', + label: labels.MONTHS, + }, + { + 'aria-label': labels.YEARS_TIME_RANGE, + 'data-test-subj': 'xpack.uptime.alerts.monitorStatus.availability.timerangeUnit.yearsOption', + key: 'y', + label: labels.YEARS, + }, +]; + +const DEFAULT_RANGE = 30; +const DEFAULT_TIMERANGE_UNIT = 'd'; +const DEFAULT_THRESHOLD = '99'; + +const isThresholdInvalid = (n: number): boolean => isNaN(n) || n <= 0 || n > 100; + +export const AvailabilityExpressionSelect: React.FC = ({ + alertParams, + isOldAlert, + setAlertParams, +}) => { + const [range, setRange] = useState(alertParams?.availability?.range ?? DEFAULT_RANGE); + const [rangeUnit, setRangeUnit] = useState( + alertParams?.availability?.rangeUnit ?? DEFAULT_TIMERANGE_UNIT + ); + const [threshold, setThreshold] = useState( + alertParams?.availability?.threshold ?? DEFAULT_THRESHOLD + ); + const [isEnabled, setIsEnabled] = useState( + // if an older version of alert is displayed, this expression should default to disabled + alertParams?.shouldCheckAvailability ?? !isOldAlert + ); + const [timerangeUnitOptions, setTimerangeUnitOptions] = useState( + TimeRangeOptions.map((opt) => + opt.key === DEFAULT_TIMERANGE_UNIT ? { ...opt, checked: 'on' } : opt + ) + ); + + const thresholdIsInvalid = isThresholdInvalid(Number(threshold)); + + useEffect(() => { + if (thresholdIsInvalid) { + setAlertParams('availability', undefined); + setAlertParams('shouldCheckAvailability', false); + } else if (isEnabled) { + setAlertParams('shouldCheckAvailability', true); + setAlertParams('availability', { + range, + rangeUnit, + threshold, + }); + } else { + setAlertParams('shouldCheckAvailability', false); + } + }, [isEnabled, range, rangeUnit, setAlertParams, threshold, thresholdIsInvalid]); + + return ( + + + setIsEnabled(!isEnabled)} + /> + + + { + setThreshold(e.target.value); + }} + /> + } + data-test-subj="xpack.uptime.alerts.monitorStatus.availability.threshold" + description={labels.ENTER_AVAILABILITY_THRESHOLD_DESCRIPTION} + id="threshold" + isEnabled={isEnabled} + isInvalid={thresholdIsInvalid} + value={labels.ENTER_AVAILABILITY_THRESHOLD_VALUE(threshold)} + /> + + + + + + } + data-test-subj="xpack.uptime.alerts.monitorStatus.availability.timerangeExpression" + description={labels.ENTER_AVAILABILITY_RANGE_UNITS_DESCRIPTION} + id="range" + isEnabled={isEnabled} + value={`${range}`} + /> + + + { + // TODO: this should not be `any` + const checkedOption = newOptions.find(({ checked }: any) => checked === 'on'); + if (checkedOption) { + setTimerangeUnitOptions(newOptions); + setRangeUnit(checkedOption.key); + } + }} + timeRangeOptions={timerangeUnitOptions} + /> + } + data-test-subj="xpack.uptime.alerts.monitorStatus.availability.timerangeUnit" + description="" + id="availability-unit" + isEnabled={isEnabled} + value={ + timerangeUnitOptions.find(({ checked }) => checked === 'on')?.label.toLowerCase() ?? + '' + } + /> + + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/down_number_select.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/down_number_select.tsx index 0eb53eb044bc50..986d55cde74632 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/down_number_select.tsx +++ b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/down_number_select.tsx @@ -10,6 +10,7 @@ import * as labels from '../translations'; import { AlertFieldNumber } from '../alert_field_number'; interface Props { + isEnabled?: boolean; defaultNumTimes?: number; hasFilters: boolean; setAlertParams: (key: string, value: any) => void; @@ -18,6 +19,7 @@ interface Props { export const DownNoExpressionSelect: React.FC = ({ defaultNumTimes, hasFilters, + isEnabled, setAlertParams, }) => { const [numTimes, setNumTimes] = useState(defaultNumTimes ?? 5); @@ -41,6 +43,7 @@ export const DownNoExpressionSelect: React.FC = ({ data-test-subj="xpack.uptime.alerts.monitorStatus.numTimesExpression" description={hasFilters ? labels.MATCHING_MONITORS_DOWN : labels.ANY_MONITOR_DOWN} id="ping-count" + isEnabled={isEnabled} value={`${numTimes} times`} /> ); diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/index.ts b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/index.ts index e6f47e744f5eaf..637d102df85d57 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/index.ts +++ b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/index.ts @@ -8,3 +8,4 @@ export { DownNoExpressionSelect } from './down_number_select'; export { FiltersExpressionsSelect } from './filters_expression_select'; export { FiltersExpressionSelectContainer } from './filters_expression_select_container'; export { TimeExpressionSelect } from './time_expression_select'; +export { StatusExpressionSelect } from './status_expression_select'; diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/status_expression_select.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/status_expression_select.tsx new file mode 100644 index 00000000000000..15c7d7a2ef32a1 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/status_expression_select.tsx @@ -0,0 +1,58 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiCheckbox } from '@elastic/eui'; +import React, { useEffect, useState } from 'react'; +import { DownNoExpressionSelect } from './down_number_select'; +import { TimeExpressionSelect } from './time_expression_select'; +import { statusExpLabels } from './translations'; + +interface Props { + alertParams: { [param: string]: any }; + hasFilters: boolean; + setAlertParams: (key: string, value: any) => void; +} + +export const StatusExpressionSelect: React.FC = ({ + alertParams, + hasFilters, + setAlertParams, +}) => { + const [isEnabled, setIsEnabled] = useState(alertParams.shouldCheckStatus ?? true); + + useEffect(() => { + setAlertParams('shouldCheckStatus', isEnabled); + }, [isEnabled, setAlertParams]); + + return ( + + + setIsEnabled(!isEnabled)} + /> + + + + + + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/time_expression_select.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/time_expression_select.tsx index 44bfbff6817c44..48593e15be0178 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/time_expression_select.tsx +++ b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/time_expression_select.tsx @@ -5,22 +5,23 @@ */ import React, { useEffect, useState } from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiFlexGroup, EuiFlexItem, EuiSelectable, EuiTitle } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { AlertExpressionPopover } from '../alert_expression_popover'; import * as labels from '../translations'; import { AlertFieldNumber } from '../alert_field_number'; import { timeExpLabels } from './translations'; +import { TimeUnitSelectable, TimeRangeOption } from './time_unit_selectable'; interface Props { defaultTimerangeCount?: number; defaultTimerangeUnit?: string; + isEnabled?: boolean; setAlertParams: (key: string, value: any) => void; } const DEFAULT_TIMERANGE_UNIT = 'm'; -const TimeRangeOptions = [ +const TimeRangeOptions: TimeRangeOption[] = [ { 'aria-label': labels.SECONDS_TIME_RANGE, 'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.secondsOption', @@ -50,6 +51,7 @@ const TimeRangeOptions = [ export const TimeExpressionSelect: React.FC = ({ defaultTimerangeCount, defaultTimerangeUnit, + isEnabled, setAlertParams, }) => { const [numUnits, setNumUnits] = useState(defaultTimerangeCount ?? 15); @@ -81,45 +83,32 @@ export const TimeExpressionSelect: React.FC = ({ /> } data-test-subj="xpack.uptime.alerts.monitorStatus.timerangeValueExpression" - description="within" + description={labels.ENTER_NUMBER_OF_TIME_UNITS_DESCRIPTION} id="timerange" - value={`last ${numUnits}`} + isEnabled={isEnabled} + value={labels.ENTER_NUMBER_OF_TIME_UNITS_VALUE(numUnits)} /> - -
- -
-
- { - if (newOptions.reduce((acc, { checked }) => acc || checked === 'on', false)) { - setTimerangeUnitOptions(newOptions); - } - }} - singleSelection={true} - listProps={{ - showIcons: true, - }} - > - {(list) => list} - - + >) => { + if (newOptions.reduce((acc, { checked }) => acc || checked === 'on', false)) { + setTimerangeUnitOptions(newOptions); + } + }} + timeRangeOptions={timerangeUnitOptions} + /> } data-test-subj="xpack.uptime.alerts.monitorStatus.timerangeUnitExpression" description="" id="timerange-unit" + isEnabled={isEnabled} value={ timerangeUnitOptions.find(({ checked }) => checked === 'on')?.label.toLowerCase() ?? '' } diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/time_unit_selectable.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/time_unit_selectable.tsx new file mode 100644 index 00000000000000..ed5842f9d36999 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/time_unit_selectable.tsx @@ -0,0 +1,52 @@ +/* + * 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 { EuiTitle, EuiSelectable } from '@elastic/eui'; + +export interface TimeRangeOption { + 'aria-label': string; + 'data-test-subj': string; + key: string; + label: string; + checked?: 'on' | 'off'; +} + +interface Props { + 'aria-label': string; + 'data-test-subj': string; + headlineText: string; + onChange: (newOptions: Array>) => void; + timeRangeOptions: TimeRangeOption[]; +} + +export const TimeUnitSelectable: React.FC = ({ + 'aria-label': ariaLabel, + 'data-test-subj': dataTestSubj, + headlineText: headlineText, + onChange, + timeRangeOptions, +}) => { + return ( + <> + +
{headlineText}
+
+ + {(list) => list} + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/translations.ts b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/translations.ts index 5fefc9f3ae35b5..64c082dc513341 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/translations.ts +++ b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/translations.ts @@ -56,6 +56,12 @@ export const alertFilterLabels = { }), }; +export const statusExpLabels = { + ENABLED_CHECKBOX: i18n.translate('xpack.uptime.alerts.monitorStatus.statusEnabledCheck.label', { + defaultMessage: 'Status check', + }), +}; + export const timeExpLabels = { OPEN_TIME_POPOVER: i18n.translate( 'xpack.uptime.alerts.monitorStatus.timerangeUnitExpression.ariaLabel', @@ -69,4 +75,10 @@ export const timeExpLabels = { defaultMessage: 'Selectable field for the time range units alerts should use', } ), + SELECT_TIME_RANGE_HEADLINE: i18n.translate( + 'xpack.uptime.alerts.monitorStatus.timerangeSelectionHeader', + { + defaultMessage: 'Select time range unit', + } + ), }; diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/old_alert_call_out.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/old_alert_call_out.tsx index eba66f7bfd5708..de9a7bae1d6702 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/old_alert_call_out.tsx +++ b/x-pack/plugins/uptime/public/components/overview/alerts/old_alert_call_out.tsx @@ -23,7 +23,7 @@ export const OldAlertCallOut: React.FC = ({ isOldAlert }) => { title={ } iconType="alert" diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/translations.ts b/x-pack/plugins/uptime/public/components/overview/alerts/translations.ts index 637fe0a108958b..b2f35ccc2c2017 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/translations.ts +++ b/x-pack/plugins/uptime/public/components/overview/alerts/translations.ts @@ -50,6 +50,39 @@ export const DAYS = i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeO defaultMessage: 'days', }); +export const WEEKS_TIME_RANGE = i18n.translate( + 'xpack.uptime.alerts.timerangeUnitSelectable.weeksOption.ariaLabel', + { + defaultMessage: '"Weeks" time range select item', + } +); + +export const WEEKS = i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.weeks', { + defaultMessage: 'weeks', +}); + +export const MONTHS_TIME_RANGE = i18n.translate( + 'xpack.uptime.alerts.timerangeUnitSelectable.monthsOption.ariaLabel', + { + defaultMessage: '"Months" time range select item', + } +); + +export const MONTHS = i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.months', { + defaultMessage: 'months', +}); + +export const YEARS_TIME_RANGE = i18n.translate( + 'xpack.uptime.alerts.timerangeUnitSelectable.yearsOption.ariaLabel', + { + defaultMessage: '"Years" time range select item', + } +); + +export const YEARS = i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.years', { + defaultMessage: 'years', +}); + export const ALERT_KUERY_BAR_ARIA = i18n.translate( 'xpack.uptime.alerts.monitorStatus.filterBar.ariaLabel', { @@ -99,6 +132,92 @@ export const ENTER_NUMBER_OF_TIME_UNITS = i18n.translate( } ); +export const ENTER_NUMBER_OF_TIME_UNITS_DESCRIPTION = i18n.translate( + 'xpack.uptime.alerts.monitorStatus.timerangeValueField.expression', + { + defaultMessage: 'within', + } +); + +export const ENTER_NUMBER_OF_TIME_UNITS_VALUE = (value: number) => + i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeValueField.value', { + defaultMessage: 'last {value}', + values: { value }, + }); + +export const ENTER_AVAILABILITY_RANGE_ENABLED = i18n.translate( + 'xpack.uptime.alerts.monitorStatus.availability.isEnabledCheckbox.label', + { + defaultMessage: 'Availability', + } +); + +export const ENTER_AVAILABILITY_RANGE_POPOVER_ARIA_LABEL = i18n.translate( + 'xpack.uptime.alerts.monitorStatus.availability.timerangeValueField.popover.ariaLabel', + { + defaultMessage: 'Specify availability tracking time range', + } +); + +export const ENTER_AVAILABILITY_RANGE_UNITS_ARIA_LABEL = i18n.translate( + 'xpack.uptime.alerts.monitorStatus.availability.timerangeValueField.ariaLabel', + { + defaultMessage: `Enter the number of units for the alert's availability check.`, + } +); + +export const ENTER_AVAILABILITY_RANGE_UNITS_DESCRIPTION = i18n.translate( + 'xpack.uptime.alerts.monitorStatus.availability.timerangeValueField.expression', + { + defaultMessage: 'within the last', + } +); + +export const ENTER_AVAILABILITY_THRESHOLD_ARIA_LABEL = i18n.translate( + 'xpack.uptime.alerts.monitorStatus.availability.threshold.ariaLabel', + { + defaultMessage: 'Specify availability thresholds for this alert', + } +); + +export const ENTER_AVAILABILITY_THRESHOLD_INPUT_ARIA_LABEL = i18n.translate( + 'xpack.uptime.alerts.monitorStatus.availability.threshold.input.ariaLabel', + { + defaultMessage: 'Input an availability threshold to check for this alert', + } +); + +export const ENTER_AVAILABILITY_THRESHOLD_DESCRIPTION = i18n.translate( + 'xpack.uptime.alerts.monitorStatus.availability.threshold.description', + { + defaultMessage: 'matching monitors are up in', + description: + 'This fragment explains that an alert will fire for monitors matching user-specified criteria', + } +); + +export const ENTER_AVAILABILITY_THRESHOLD_VALUE = (value: string) => + i18n.translate('xpack.uptime.alerts.monitorStatus.availability.threshold.value', { + defaultMessage: '< {value}% of checks', + description: + 'This fragment specifies criteria that will cause an alert to fire for uptime monitors', + values: { value }, + }); + +export const ENTER_AVAILABILITY_RANGE_SELECT_ARIA = i18n.translate( + 'xpack.uptime.alerts.monitorStatus.availability.unit.selectable', + { + defaultMessage: 'Use this select to set the availability range units for this alert', + } +); + +export const ENTER_AVAILABILITY_RANGE_SELECT_HEADLINE = i18n.translate( + 'xpack.uptime.alerts.monitorStatus.availability.unit.headline', + { + defaultMessage: 'Select time range unit', + } +); + export const ADD_FILTER = i18n.translate('xpack.uptime.alerts.monitorStatus.addFilter', { defaultMessage: `Add filter`, }); diff --git a/x-pack/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts b/x-pack/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts index 7ca5e7438d28a6..cfcb414f4815df 100644 --- a/x-pack/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts +++ b/x-pack/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts @@ -13,6 +13,7 @@ describe('monitor status alert type', () => { beforeEach(() => { params = { numTimes: 5, + shouldCheckStatus: true, timerangeCount: 15, timerangeUnit: 'm', }; @@ -24,9 +25,9 @@ describe('monitor status alert type', () => { "errors": Object { "typeCheckFailure": "Provided parameters do not conform to the expected type.", "typeCheckParsingMessage": Array [ - "Invalid value undefined supplied to : ({ numTimes: number, timerangeCount: number, timerangeUnit: string } & Partial<{ search: string, filters: { monitor.type: Array, observer.geo.name: Array, tags: Array, url.port: Array } }>)/0: { numTimes: number, timerangeCount: number, timerangeUnit: string }/numTimes: number", - "Invalid value undefined supplied to : ({ numTimes: number, timerangeCount: number, timerangeUnit: string } & Partial<{ search: string, filters: { monitor.type: Array, observer.geo.name: Array, tags: Array, url.port: Array } }>)/0: { numTimes: number, timerangeCount: number, timerangeUnit: string }/timerangeCount: number", - "Invalid value undefined supplied to : ({ numTimes: number, timerangeCount: number, timerangeUnit: string } & Partial<{ search: string, filters: { monitor.type: Array, observer.geo.name: Array, tags: Array, url.port: Array } }>)/0: { numTimes: number, timerangeCount: number, timerangeUnit: string }/timerangeUnit: string", + "Invalid value undefined supplied to : ({ numTimes: number, timerangeCount: number, timerangeUnit: string } & Partial<{ search: string, filters: { monitor.type: Array, observer.geo.name: Array, tags: Array, url.port: Array }, shouldCheckStatus: boolean }>)/0: { numTimes: number, timerangeCount: number, timerangeUnit: string }/numTimes: number", + "Invalid value undefined supplied to : ({ numTimes: number, timerangeCount: number, timerangeUnit: string } & Partial<{ search: string, filters: { monitor.type: Array, observer.geo.name: Array, tags: Array, url.port: Array }, shouldCheckStatus: boolean }>)/0: { numTimes: number, timerangeCount: number, timerangeUnit: string }/timerangeCount: number", + "Invalid value undefined supplied to : ({ numTimes: number, timerangeCount: number, timerangeUnit: string } & Partial<{ search: string, filters: { monitor.type: Array, observer.geo.name: Array, tags: Array, url.port: Array }, shouldCheckStatus: boolean }>)/0: { numTimes: number, timerangeCount: number, timerangeUnit: string }/timerangeUnit: string", ], }, } @@ -43,6 +44,7 @@ describe('monitor status alert type', () => { to: 'now', }, filters: '{foo: "bar"}', + shouldCheckStatus: true, }) ).toMatchInlineSnapshot(` Object { @@ -51,6 +53,73 @@ describe('monitor status alert type', () => { `); }); + describe('should check flags', () => { + it('does not pass without one or more should check flags', () => { + params.shouldCheckStatus = false; + expect(validate(params)).toMatchInlineSnapshot(` + Object { + "errors": Object { + "noAlertSelected": "Alert must check for monitor status or monitor availability.", + }, + } + `); + }); + + it('does not pass when availability is defined, but both check flags are false', () => { + params.shouldCheckStatus = false; + params.shouldCheckAvailability = false; + params.availability = { + range: 3, + rangeUnit: 'w', + threshold: 98.3, + }; + expect(validate(params)).toMatchInlineSnapshot(` + Object { + "errors": Object { + "noAlertSelected": "Alert must check for monitor status or monitor availability.", + }, + } + `); + }); + + it('passes when status check is defined and flag is set to true', () => { + params.shouldCheckStatus = false; + params.shouldCheckAvailability = true; + params.availability = { + range: 3, + rangeUnit: 'w', + threshold: 98.3, + }; + expect(validate(params)).toMatchInlineSnapshot(` + Object { + "errors": Object {}, + } + `); + }); + + it('passes when status check and availability check flags are both true', () => { + params.shouldCheckAvailability = true; + params.availability = { + range: 3, + rangeUnit: 'w', + threshold: 98.3, + }; + expect(validate(params)).toMatchInlineSnapshot(` + Object { + "errors": Object {}, + } + `); + }); + + it('passes when availability check is defined and flag is set to true', () => { + expect(validate(params)).toMatchInlineSnapshot(` + Object { + "errors": Object {}, + } + `); + }); + }); + describe('timerange', () => { it('has invalid timerangeCount value', () => { expect(validate({ ...params, timerangeCount: 0 })).toMatchInlineSnapshot(` @@ -81,7 +150,7 @@ describe('monitor status alert type', () => { "errors": Object { "typeCheckFailure": "Provided parameters do not conform to the expected type.", "typeCheckParsingMessage": Array [ - "Invalid value undefined supplied to : ({ numTimes: number, timerangeCount: number, timerangeUnit: string } & Partial<{ search: string, filters: { monitor.type: Array, observer.geo.name: Array, tags: Array, url.port: Array } }>)/0: { numTimes: number, timerangeCount: number, timerangeUnit: string }/numTimes: number", + "Invalid value undefined supplied to : ({ numTimes: number, timerangeCount: number, timerangeUnit: string } & Partial<{ search: string, filters: { monitor.type: Array, observer.geo.name: Array, tags: Array, url.port: Array }, shouldCheckStatus: boolean }>)/0: { numTimes: number, timerangeCount: number, timerangeUnit: string }/numTimes: number", ], }, } @@ -94,7 +163,7 @@ describe('monitor status alert type', () => { "errors": Object { "typeCheckFailure": "Provided parameters do not conform to the expected type.", "typeCheckParsingMessage": Array [ - "Invalid value \\"this isn't a number\\" supplied to : ({ numTimes: number, timerangeCount: number, timerangeUnit: string } & Partial<{ search: string, filters: { monitor.type: Array, observer.geo.name: Array, tags: Array, url.port: Array } }>)/0: { numTimes: number, timerangeCount: number, timerangeUnit: string }/numTimes: number", + "Invalid value \\"this isn't a number\\" supplied to : ({ numTimes: number, timerangeCount: number, timerangeUnit: string } & Partial<{ search: string, filters: { monitor.type: Array, observer.geo.name: Array, tags: Array, url.port: Array }, shouldCheckStatus: boolean }>)/0: { numTimes: number, timerangeCount: number, timerangeUnit: string }/numTimes: number", ], }, } @@ -134,7 +203,7 @@ describe('monitor status alert type', () => { "alertParamsExpression": [Function], "defaultActionMessage": "{{context.message}} Last triggered at: {{state.lastTriggeredAt}} - {{context.downMonitorsWithGeo}}", + ", "iconClass": "uptimeApp", "id": "xpack.uptime.alerts.monitorStatus", "name": { +export const validate = (alertParams: any) => { const errors: Record = {}; const decoded = AtomicStatusCheckParamsType.decode(alertParams); const oldDecoded = StatusCheckParamsType.decode(alertParams); + const availabilityDecoded = MonitorAvailabilityType.decode(alertParams); - if (!isRight(decoded) && !isRight(oldDecoded)) { + if (!isRight(decoded) && !isRight(oldDecoded) && !isRight(availabilityDecoded)) { return { errors: { typeCheckFailure: 'Provided parameters do not conform to the expected type.', @@ -30,7 +35,19 @@ export const validate = (alertParams: unknown) => { }, }; } - if (isRight(decoded)) { + + if ( + !(alertParams.shouldCheckAvailability ?? false) && + !(alertParams.shouldCheckStatus ?? false) + ) { + return { + errors: { + noAlertSelected: 'Alert must check for monitor status or monitor availability.', + }, + }; + } + + if (isRight(decoded) && decoded.right.shouldCheckStatus) { const { numTimes, timerangeCount } = decoded.right; if (numTimes < 1) { errors.invalidNumTimes = 'Number of alert check down times must be an integer greater than 0'; diff --git a/x-pack/plugins/uptime/public/lib/alert_types/translations.ts b/x-pack/plugins/uptime/public/lib/alert_types/translations.ts index cdf3cd107b00f9..11fa70bc56f4a1 100644 --- a/x-pack/plugins/uptime/public/lib/alert_types/translations.ts +++ b/x-pack/plugins/uptime/public/lib/alert_types/translations.ts @@ -8,11 +8,10 @@ import { i18n } from '@kbn/i18n'; export const MonitorStatusTranslations = { defaultActionMessage: i18n.translate('xpack.uptime.alerts.monitorStatus.defaultActionMessage', { - defaultMessage: '{contextMessage}\nLast triggered at: {lastTriggered}\n{downMonitors}', + defaultMessage: '{contextMessage}\nLast triggered at: {lastTriggered}\n', values: { contextMessage: '{{context.message}}', lastTriggered: '{{state.lastTriggeredAt}}', - downMonitors: '{{context.downMonitorsWithGeo}}', }, }), name: i18n.translate('xpack.uptime.alerts.monitorStatus.clientName', { diff --git a/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts b/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts index 6cd836525c0775..d85752768b47bb 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts @@ -7,10 +7,11 @@ import { contextMessage, fullListByIdAndLocation, - genFilterString, + generateFilterDSL, hasFilters, statusCheckAlertFactory, uniqueMonitorIds, + availabilityMessage, } from '../status_check'; import { GetMonitorStatusResult } from '../../requests'; import { AlertType } from '../../../../../alerts/server'; @@ -45,7 +46,12 @@ const bootstrapDependencies = (customRequests?: any) => { * @param state the state the alert maintains */ const mockOptions = ( - params = { numTimes: 5, locations: [], timerange: { from: 'now-15m', to: 'now' } }, + params: any = { + numTimes: 5, + locations: [], + timerange: { from: 'now-15m', to: 'now' }, + shouldCheckStatus: true, + }, services = alertsMock.createAlertServices(), state = {} ): any => { @@ -95,6 +101,7 @@ describe('status check alert', () => { }, "locations": Array [], "numTimes": 5, + "shouldCheckStatus": true, "timerange": Object { "from": "now-15m", "to": "now", @@ -140,6 +147,7 @@ describe('status check alert', () => { }, "locations": Array [], "numTimes": 5, + "shouldCheckStatus": true, "timerange": Object { "from": "now-15m", "to": "now", @@ -187,6 +195,443 @@ describe('status check alert', () => { ] `); }); + + it('supports 7.7 alert format', async () => { + toISOStringSpy.mockImplementation(() => '7.7 date'); + const mockGetter = jest.fn(); + mockGetter.mockReturnValue([ + { + monitor_id: 'first', + location: 'harrisburg', + count: 234, + status: 'down', + }, + { + monitor_id: 'first', + location: 'fairbanks', + count: 234, + status: 'down', + }, + ]); + const { server, libs } = bootstrapDependencies({ + getMonitorStatus: mockGetter, + getIndexPattern: jest.fn(), + }); + const alert = statusCheckAlertFactory(server, libs); + const options = mockOptions({ + numTimes: 4, + timerange: { from: 'now-14h', to: 'now' }, + locations: ['fairbanks'], + filters: '', + }); + const alertServices: AlertServicesMock = options.services; + const state = await alert.executor(options); + const [{ value: alertInstanceMock }] = alertServices.alertInstanceFactory.mock.results; + expect(alertInstanceMock.replaceState).toHaveBeenCalledTimes(1); + expect(alertInstanceMock.replaceState.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "currentTriggerStarted": "7.7 date", + "firstCheckedAt": "7.7 date", + "firstTriggeredAt": "7.7 date", + "isTriggered": true, + "lastCheckedAt": "7.7 date", + "lastResolvedAt": undefined, + "lastTriggeredAt": "7.7 date", + "monitors": Array [ + Object { + "count": 234, + "location": "fairbanks", + "monitor_id": "first", + "status": "down", + }, + Object { + "count": 234, + "location": "harrisburg", + "monitor_id": "first", + "status": "down", + }, + ], + }, + ] + `); + expect(state).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "7.7 date", + "firstCheckedAt": "7.7 date", + "firstTriggeredAt": "7.7 date", + "isTriggered": true, + "lastCheckedAt": "7.7 date", + "lastResolvedAt": undefined, + "lastTriggeredAt": "7.7 date", + } + `); + }); + + it('supports 7.8 alert format', async () => { + expect.assertions(5); + toISOStringSpy.mockImplementation(() => 'foo date string'); + const mockGetter = jest.fn(); + mockGetter.mockReturnValue([ + { + monitor_id: 'first', + location: 'harrisburg', + count: 234, + status: 'down', + }, + { + monitor_id: 'first', + location: 'fairbanks', + count: 234, + status: 'down', + }, + ]); + const { server, libs } = bootstrapDependencies({ + getMonitorStatus: mockGetter, + getIndexPattern: jest.fn(), + }); + const alert = statusCheckAlertFactory(server, libs); + const options = mockOptions({ + numTimes: 3, + timerangeUnit: 'm', + timerangeCount: 15, + search: 'monitor.ip : * ', + filters: { + 'url.port': ['12349', '5601', '443'], + 'observer.geo.name': ['harrisburg'], + 'monitor.type': ['http'], + tags: ['unsecured', 'containers', 'org:google'], + }, + }); + const alertServices: AlertServicesMock = options.services; + const state = await alert.executor(options); + const [{ value: alertInstanceMock }] = alertServices.alertInstanceFactory.mock.results; + expect(mockGetter).toHaveBeenCalledTimes(1); + expect(mockGetter.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "callES": [MockFunction], + "dynamicSettings": Object { + "certAgeThreshold": 730, + "certExpirationThreshold": 30, + "heartbeatIndices": "heartbeat-8*", + }, + "filters": "{\\"bool\\":{\\"filter\\":[{\\"bool\\":{\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"url.port\\":12349}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"url.port\\":5601}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"url.port\\":443}}],\\"minimum_should_match\\":1}}],\\"minimum_should_match\\":1}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"observer.geo.name\\":\\"harrisburg\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"monitor.type\\":\\"http\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"tags\\":\\"unsecured\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"tags\\":\\"containers\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match_phrase\\":{\\"tags\\":\\"org:google\\"}}],\\"minimum_should_match\\":1}}],\\"minimum_should_match\\":1}}],\\"minimum_should_match\\":1}}]}}]}}]}},{\\"bool\\":{\\"should\\":[{\\"exists\\":{\\"field\\":\\"monitor.ip\\"}}],\\"minimum_should_match\\":1}}]}}", + "locations": Array [], + "numTimes": 3, + "timerange": Object { + "from": "now-15m", + "to": "now", + }, + }, + ] + `); + expect(alertInstanceMock.replaceState).toHaveBeenCalledTimes(1); + expect(alertInstanceMock.replaceState.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "currentTriggerStarted": "foo date string", + "firstCheckedAt": "foo date string", + "firstTriggeredAt": "foo date string", + "isTriggered": true, + "lastCheckedAt": "foo date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": "foo date string", + "monitors": Array [ + Object { + "count": 234, + "location": "fairbanks", + "monitor_id": "first", + "status": "down", + }, + Object { + "count": 234, + "location": "harrisburg", + "monitor_id": "first", + "status": "down", + }, + ], + }, + ] + `); + expect(state).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "foo date string", + "firstCheckedAt": "foo date string", + "firstTriggeredAt": "foo date string", + "isTriggered": true, + "lastCheckedAt": "foo date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": "foo date string", + } + `); + }); + + it('supports searches', async () => { + toISOStringSpy.mockImplementation(() => 'search test'); + const mockGetter = jest.fn(); + mockGetter.mockReturnValue([]); + const { server, libs } = bootstrapDependencies({ + getIndexPattern: jest.fn(), + getMonitorStatus: mockGetter, + }); + const alert = statusCheckAlertFactory(server, libs); + const options = mockOptions({ + numTimes: 20, + timerangeCount: 30, + timerangeUnit: 'h', + filters: { + 'monitor.type': ['http'], + 'observer.geo.name': [], + tags: [], + 'url.port': [], + }, + search: 'url.full: *', + }); + await alert.executor(options); + expect(mockGetter).toHaveBeenCalledTimes(1); + expect(mockGetter.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "callES": [MockFunction], + "dynamicSettings": Object { + "certAgeThreshold": 730, + "certExpirationThreshold": 30, + "heartbeatIndices": "heartbeat-8*", + }, + "filters": "{\\"bool\\":{\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"monitor.type\\":\\"http\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"exists\\":{\\"field\\":\\"url.full\\"}}],\\"minimum_should_match\\":1}}]}}", + "locations": Array [], + "numTimes": 20, + "timerange": Object { + "from": "now-30h", + "to": "now", + }, + }, + ] + `); + }); + + it('supports availability checks', async () => { + expect.assertions(8); + toISOStringSpy.mockImplementation(() => 'availability test'); + const mockGetter = jest.fn(); + mockGetter.mockReturnValue([ + { + monitor_id: 'first', + location: 'harrisburg', + count: 234, + status: 'down', + }, + { + monitor_id: 'first', + location: 'fairbanks', + count: 234, + status: 'down', + }, + ]); + const mockAvailability = jest.fn(); + mockAvailability.mockReturnValue([ + { + monitorId: 'foo', + location: 'harrisburg', + name: 'Foo', + url: 'https://foo.com', + up: 2341, + down: 17, + availabilityRatio: 0.992790500424088, + }, + { + monitorId: 'foo', + location: 'fairbanks', + name: 'Foo', + url: 'https://foo.com', + up: 2343, + down: 47, + availabilityRatio: 0.980334728033473, + }, + { + monitorId: 'unreliable', + location: 'fairbanks', + name: 'Unreliable', + url: 'https://unreliable.co', + up: 2134, + down: 213, + availabilityRatio: 0.909245845760545, + }, + { + monitorId: 'no-name', + location: 'fairbanks', + url: 'https://no-name.co', + up: 2134, + down: 213, + availabilityRatio: 0.909245845760545, + }, + ]); + const { server, libs } = bootstrapDependencies({ + getMonitorAvailability: mockAvailability, + getMonitorStatus: mockGetter, + getIndexPattern: jest.fn(), + }); + const alert = statusCheckAlertFactory(server, libs); + const options = mockOptions({ + availability: { + range: 35, + rangeUnit: 'd', + threshold: '99.34', + }, + filters: { + 'url.port': ['12349', '5601', '443'], + 'observer.geo.name': ['harrisburg'], + 'monitor.type': ['http'], + tags: ['unsecured', 'containers', 'org:google'], + }, + shouldCheckAvailability: true, + }); + const alertServices: AlertServicesMock = options.services; + const state = await alert.executor(options); + const [{ value: alertInstanceMock }] = alertServices.alertInstanceFactory.mock.results; + expect(alertInstanceMock.replaceState).toHaveBeenCalledTimes(1); + expect(alertInstanceMock.replaceState.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "currentTriggerStarted": "availability test", + "firstCheckedAt": "availability test", + "firstTriggeredAt": "availability test", + "isTriggered": true, + "lastCheckedAt": "availability test", + "lastResolvedAt": undefined, + "lastTriggeredAt": "availability test", + "monitors": Array [], + }, + ] + `); + expect(alertInstanceMock.scheduleActions).toHaveBeenCalledTimes(1); + expect(alertInstanceMock.scheduleActions.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "xpack.uptime.alerts.actionGroups.monitorStatus", + Object { + "downMonitorsWithGeo": "", + "message": "Top 3 Monitors Below Availability Threshold (99.34 %): + Unreliable(https://unreliable.co): 90.925% + no-name(https://no-name.co): 90.925% + Foo(https://foo.com): 98.033% + ", + }, + ], + ] + `); + expect(mockGetter).not.toHaveBeenCalled(); + expect(mockAvailability).toHaveBeenCalledTimes(1); + expect(mockAvailability.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "callES": [MockFunction], + "dynamicSettings": Object { + "certAgeThreshold": 730, + "certExpirationThreshold": 30, + "heartbeatIndices": "heartbeat-8*", + }, + "filters": "{\\"bool\\":{\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"url.port\\":12349}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"url.port\\":5601}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"url.port\\":443}}],\\"minimum_should_match\\":1}}],\\"minimum_should_match\\":1}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"observer.geo.name\\":\\"harrisburg\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"monitor.type\\":\\"http\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"tags\\":\\"unsecured\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"tags\\":\\"containers\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match_phrase\\":{\\"tags\\":\\"org:google\\"}}],\\"minimum_should_match\\":1}}],\\"minimum_should_match\\":1}}],\\"minimum_should_match\\":1}}]}}]}}]}}", + "range": 35, + "rangeUnit": "d", + "threshold": "99.34", + }, + ] + `); + expect(state).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "availability test", + "firstTriggeredAt": undefined, + "isTriggered": false, + "lastCheckedAt": "availability test", + "lastResolvedAt": undefined, + "lastTriggeredAt": undefined, + } + `); + }); + + it('supports availability checks with search', async () => { + expect.assertions(2); + toISOStringSpy.mockImplementation(() => 'availability with search'); + const mockGetter = jest.fn(); + mockGetter.mockReturnValue([]); + const mockAvailability = jest.fn(); + mockAvailability.mockReturnValue([]); + const { server, libs } = bootstrapDependencies({ + getMonitorAvailability: mockAvailability, + getIndexPattern: jest.fn(), + }); + const alert = statusCheckAlertFactory(server, libs); + const options = mockOptions({ + availability: { + range: 23, + rangeUnit: 'w', + threshold: '90', + }, + search: 'ur.port: *', + shouldCheckAvailability: true, + }); + await alert.executor(options); + expect(mockAvailability).toHaveBeenCalledTimes(1); + expect(mockAvailability.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "callES": [MockFunction], + "dynamicSettings": Object { + "certAgeThreshold": 730, + "certExpirationThreshold": 30, + "heartbeatIndices": "heartbeat-8*", + }, + "filters": "{\\"bool\\":{\\"should\\":[{\\"exists\\":{\\"field\\":\\"ur.port\\"}}],\\"minimum_should_match\\":1}}", + "range": 23, + "rangeUnit": "w", + "threshold": "90", + }, + ] + `); + }); + + it('supports availability checks with no filter or search', async () => { + expect.assertions(2); + toISOStringSpy.mockImplementation(() => 'availability with search'); + const mockGetter = jest.fn(); + mockGetter.mockReturnValue([]); + const mockAvailability = jest.fn(); + mockAvailability.mockReturnValue([]); + const { server, libs } = bootstrapDependencies({ + getMonitorAvailability: mockAvailability, + getIndexPattern: jest.fn(), + }); + const alert = statusCheckAlertFactory(server, libs); + const options = mockOptions({ + availability: { + range: 23, + rangeUnit: 'w', + threshold: '90', + }, + shouldCheckAvailability: true, + }); + await alert.executor(options); + expect(mockAvailability).toHaveBeenCalledTimes(1); + expect(mockAvailability.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "callES": [MockFunction], + "dynamicSettings": Object { + "certAgeThreshold": 730, + "certExpirationThreshold": 30, + "heartbeatIndices": "heartbeat-8*", + }, + "filters": undefined, + "range": 23, + "rangeUnit": "w", + "threshold": "90", + }, + ] + `); + }); }); describe('fullListByIdAndLocation', () => { @@ -311,13 +756,17 @@ describe('status check alert', () => { // @ts-ignore the `props` key here isn't described expect(Object.keys(alert.validate?.params?.props ?? {})).toMatchInlineSnapshot(` Array [ + "availability", "filters", "locations", "numTimes", "search", + "shouldCheckStatus", + "shouldCheckAvailability", "timerangeCount", "timerangeUnit", "timerange", + "version", ] `); }); @@ -370,11 +819,11 @@ describe('status check alert', () => { mockGetIndexPattern.mockReturnValue(undefined); it('returns `undefined` for no filters or search', async () => { - expect(await genFilterString(mockGetIndexPattern)).toBeUndefined(); + expect(await generateFilterDSL(mockGetIndexPattern)).toBeUndefined(); }); it('creates a filter string for filters only', async () => { - const res = await genFilterString(mockGetIndexPattern, { + const res = await generateFilterDSL(mockGetIndexPattern, { 'monitor.type': [], 'observer.geo.name': ['us-east', 'us-west'], tags: [], @@ -416,7 +865,7 @@ describe('status check alert', () => { }); it('creates a filter string for search only', async () => { - expect(await genFilterString(mockGetIndexPattern, undefined, 'monitor.id: "kibana-dev"')) + expect(await generateFilterDSL(mockGetIndexPattern, undefined, 'monitor.id: "kibana-dev"')) .toMatchInlineSnapshot(` Object { "bool": Object { @@ -434,7 +883,7 @@ describe('status check alert', () => { }); it('creates a filter string for filters and string', async () => { - const res = await genFilterString( + const res = await generateFilterDSL( mockGetIndexPattern, { 'monitor.type': [], @@ -617,25 +1066,137 @@ describe('status check alert', () => { }); it('creates a message with appropriate number of monitors', () => { - expect(contextMessage(ids, 3)).toMatchInlineSnapshot( + expect(contextMessage(ids, 3, [], '0', false, true)).toMatchInlineSnapshot( `"Down monitors: first, second, third... and 2 other monitors"` ); }); it('throws an error if `max` is less than 2', () => { - expect(() => contextMessage(ids, 1)).toThrowErrorMatchingInlineSnapshot( + expect(() => contextMessage(ids, 1, [], '0', false, true)).toThrowErrorMatchingInlineSnapshot( '"Maximum value must be greater than 2, received 1."' ); }); it('returns only the ids if length < max', () => { - expect(contextMessage(ids.slice(0, 2), 3)).toMatchInlineSnapshot( + expect(contextMessage(ids.slice(0, 2), 3, [], '0', false, true)).toMatchInlineSnapshot( `"Down monitors: first, second"` ); }); it('returns a default message when no monitors are provided', () => { - expect(contextMessage([], 3)).toMatchInlineSnapshot(`"No down monitor IDs received"`); + expect(contextMessage([], 3, [], '0', false, true)).toMatchInlineSnapshot( + `"No down monitor IDs received"` + ); + }); + }); + + describe('availabilityMessage', () => { + it('creates message for singular item', () => { + expect( + availabilityMessage( + [ + { + monitorId: 'test-node-service', + location: 'fairbanks', + name: 'Test Node Service', + url: 'http://localhost:12349', + up: 821.0, + down: 2450.0, + availabilityRatio: 0.25099357994497096, + }, + ], + '59' + ) + ).toMatchInlineSnapshot(` + "Monitor Below Availability Threshold (59 %): + Test Node Service(http://localhost:12349): 25.099% + " + `); + }); + + it('creates message for multiple items', () => { + expect( + availabilityMessage( + [ + { + monitorId: 'test-node-service', + location: 'fairbanks', + name: 'Test Node Service', + url: 'http://localhost:12349', + up: 821.0, + down: 2450.0, + availabilityRatio: 0.25099357994497096, + }, + { + monitorId: 'test-node-service', + location: 'harrisburg', + name: 'Test Node Service', + url: 'http://localhost:12349', + up: 3389.0, + down: 2450.0, + availabilityRatio: 0.5804076040417879, + }, + ], + '59' + ) + ).toMatchInlineSnapshot(` + "Top 2 Monitors Below Availability Threshold (59 %): + Test Node Service(http://localhost:12349): 25.099% + Test Node Service(http://localhost:12349): 58.041% + " + `); + }); + + it('caps message for multiple items', () => { + expect( + availabilityMessage( + [ + { + monitorId: 'test-node-service', + location: 'fairbanks', + name: 'Test Node Service', + url: 'http://localhost:12349', + up: 821.0, + down: 2450.0, + availabilityRatio: 0.250993579944971, + }, + { + monitorId: 'test-node-service', + location: 'harrisburg', + name: 'Test Node Service', + url: 'http://localhost:12349', + up: 3389.0, + down: 2450.0, + availabilityRatio: 0.58040760404178, + }, + { + monitorId: 'test-node-service', + location: 'berlin', + name: 'Test Node Service', + url: 'http://localhost:12349', + up: 3645.0, + down: 2982.0, + availabilityRatio: 0.550022634676324, + }, + { + monitorId: 'test-node-service', + location: 'st paul', + name: 'Test Node Service', + url: 'http://localhost:12349', + up: 3601.0, + down: 2681.0, + availabilityRatio: 0.573225087551735, + }, + ], + '59' + ) + ).toMatchInlineSnapshot(` + "Top 3 Monitors Below Availability Threshold (59 %): + Test Node Service(http://localhost:12349): 25.099% + Test Node Service(http://localhost:12349): 55.002% + Test Node Service(http://localhost:12349): 57.323% + " + `); }); }); }); diff --git a/x-pack/plugins/uptime/server/lib/alerts/status_check.ts b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts index cd42082b42c843..2117ac4b7ed4ef 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/status_check.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts @@ -18,12 +18,16 @@ import { StatusCheckParams, StatusCheckFilters, AtomicStatusCheckParamsType, + MonitorAvailabilityType, + DynamicSettings, } from '../../../common/runtime_types'; import { ACTION_GROUP_DEFINITIONS } from '../../../common/constants'; import { savedObjectsAdapter } from '../saved_objects'; import { updateState } from './common'; import { commonStateTranslations } from './translations'; import { stringifyKueries, combineFiltersAndUserSearch } from '../../../common/lib'; +import { GetMonitorAvailabilityResult } from '../requests/get_monitor_availability'; +import { UMServerLibs } from '../lib'; const { MONITOR_STATUS } = ACTION_GROUP_DEFINITIONS; @@ -37,53 +41,141 @@ export const uniqueMonitorIds = (items: GetMonitorStatusResult[]): Set = return acc; }, new Set()); +const sortAvailabilityResultByRatioAsc = ( + a: GetMonitorAvailabilityResult, + b: GetMonitorAvailabilityResult +): number => (a.availabilityRatio ?? 100) - (b.availabilityRatio ?? 100); + +/** + * Map an availability result object to a descriptive string. + */ +const mapAvailabilityResultToString = ({ + availabilityRatio, + name, + monitorId, + url, +}: GetMonitorAvailabilityResult) => + i18n.translate('xpack.uptime.alerts.availability.monitorSummary', { + defaultMessage: '{nameOrId}({url}): {availabilityRatio}%', + values: { + nameOrId: name || monitorId, + url, + availabilityRatio: ((availabilityRatio ?? 1.0) * 100).toPrecision(5), + }, + }); + +const reduceAvailabilityStringsToMessage = (threshold: string) => ( + prev: string, + cur: string, + _ind: number, + array: string[] +) => { + let prefix: string = ''; + if (prev !== '') { + prefix = prev; + } else if (array.length > 1) { + prefix = i18n.translate('xpack.uptime.alerts.availability.multiItemTitle', { + defaultMessage: `Top {monitorCount} Monitors Below Availability Threshold ({threshold} %):\n`, + values: { + monitorCount: Math.min(array.length, MESSAGE_AVAILABILITY_MAX), + threshold, + }, + }); + } else { + prefix = i18n.translate('xpack.uptime.alerts.availability.singleItemTitle', { + defaultMessage: `Monitor Below Availability Threshold ({threshold} %):\n`, + values: { threshold }, + }); + } + return prefix + `${cur}\n`; +}; + +const MESSAGE_AVAILABILITY_MAX = 3; + +/** + * Creates a summary message from a list of availability check result objects. + * @param availabilityResult the list of results + * @param threshold the threshold used by the check + */ +export const availabilityMessage = ( + availabilityResult: GetMonitorAvailabilityResult[], + threshold: string, + max: number = MESSAGE_AVAILABILITY_MAX +): string => { + return availabilityResult.length > 0 + ? // if there are results, map each item to a descriptive string, and reduce the list + availabilityResult + .sort(sortAvailabilityResultByRatioAsc) + .slice(0, max) + .map(mapAvailabilityResultToString) + .reduce(reduceAvailabilityStringsToMessage(threshold), '') + : // if there are no results, return an empty list default string + i18n.translate('xpack.uptime.alerts.availability.emptyMessage', { + defaultMessage: `No monitors were below Availability Threshold ({threshold} %)`, + values: { + threshold, + }, + }); +}; + /** * Generates a message to include in contexts of alerts. * @param monitors the list of monitors to include in the message * @param max the maximum number of items the summary should contain */ -export const contextMessage = (monitorIds: string[], max: number): string => { +export const contextMessage = ( + monitorIds: string[], + max: number, + availabilityResult: GetMonitorAvailabilityResult[], + availabilityThreshold: string, + availabilityWasChecked: boolean, + statusWasChecked: boolean +): string => { const MIN = 2; if (max < MIN) throw new Error(`Maximum value must be greater than ${MIN}, received ${max}.`); // generate the message - let message; - if (monitorIds.length === 1) { - message = i18n.translate('xpack.uptime.alerts.message.singularTitle', { - defaultMessage: 'Down monitor: ', - }); - } else if (monitorIds.length) { - message = i18n.translate('xpack.uptime.alerts.message.multipleTitle', { - defaultMessage: 'Down monitors: ', - }); - } - // this shouldn't happen because the function should only be called - // when > 0 monitors are down - else { - message = i18n.translate('xpack.uptime.alerts.message.emptyTitle', { - defaultMessage: 'No down monitor IDs received', - }); - } - - for (let i = 0; i < monitorIds.length; i++) { - const id = monitorIds[i]; - if (i === max) { - return ( - message + - i18n.translate('xpack.uptime.alerts.message.overflowBody', { - defaultMessage: `... and {overflowCount} other monitors`, - values: { - overflowCount: monitorIds.length - i, - }, - }) - ); - } else if (i === 0) { - message = message + id; + let message = ''; + if (statusWasChecked) { + if (monitorIds.length === 1) { + message = i18n.translate('xpack.uptime.alerts.message.singularTitle', { + defaultMessage: 'Down monitor: ', + }); + } else if (monitorIds.length) { + message = i18n.translate('xpack.uptime.alerts.message.multipleTitle', { + defaultMessage: 'Down monitors: ', + }); } else { - message = message + `, ${id}`; + message = i18n.translate('xpack.uptime.alerts.message.emptyTitle', { + defaultMessage: 'No down monitor IDs received', + }); + } + + for (let i = 0; i < monitorIds.length; i++) { + const id = monitorIds[i]; + if (i === max) { + message = + message + + i18n.translate('xpack.uptime.alerts.message.overflowBody', { + defaultMessage: `... and {overflowCount} other monitors`, + values: { + overflowCount: monitorIds.length - i, + }, + }); + break; + } else if (i === 0) { + message = message + id; + } else { + message = message + `, ${id}`; + } } } + if (availabilityWasChecked) { + const availabilityMsg = availabilityMessage(availabilityResult, availabilityThreshold); + return message ? message + '\n' + availabilityMsg : availabilityMsg; + } + return message; }; @@ -142,7 +234,7 @@ export const hasFilters = (filters?: StatusCheckFilters) => { return false; }; -export const genFilterString = async ( +export const generateFilterDSL = async ( getIndexPattern: () => Promise, filters?: StatusCheckFilters, search?: string @@ -170,6 +262,25 @@ export const genFilterString = async ( ); }; +const formatFilterString = async ( + libs: UMServerLibs, + dynamicSettings: DynamicSettings, + options: AlertExecutorOptions, + filters?: StatusCheckFilters, + search?: string +) => + JSON.stringify( + await generateFilterDSL( + () => + libs.requests.getIndexPattern({ + callES: options.services.callCluster, + dynamicSettings, + }), + filters, + search + ) + ); + export const statusCheckAlertFactory: UptimeAlertTypeFactory = (_server, libs) => ({ id: 'xpack.uptime.alerts.monitorStatus', name: i18n.translate('xpack.uptime.alerts.monitorStatus', { @@ -177,8 +288,16 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = (_server, libs) = }), validate: { params: schema.object({ + availability: schema.maybe( + schema.object({ + range: schema.number(), + rangeUnit: schema.string(), + threshold: schema.string(), + }) + ), filters: schema.maybe( schema.oneOf([ + // deprecated schema.object({ 'monitor.type': schema.maybe(schema.arrayOf(schema.string())), 'observer.geo.name': schema.maybe(schema.arrayOf(schema.string())), @@ -188,17 +307,22 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = (_server, libs) = schema.string(), ]) ), + // deprecated locations: schema.maybe(schema.arrayOf(schema.string())), numTimes: schema.number(), search: schema.maybe(schema.string()), + shouldCheckStatus: schema.maybe(schema.boolean()), + shouldCheckAvailability: schema.maybe(schema.boolean()), timerangeCount: schema.maybe(schema.number()), timerangeUnit: schema.maybe(schema.string()), + // deprecated timerange: schema.maybe( schema.object({ from: schema.string(), to: schema.string(), }) ), + version: schema.maybe(schema.number()), }), }, defaultActionGroupId: MONITOR_STATUS.id, @@ -239,22 +363,14 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = (_server, libs) = options.services.savedObjectsClient ); const atomicDecoded = AtomicStatusCheckParamsType.decode(rawParams); + const availabilityDecoded = MonitorAvailabilityType.decode(rawParams); const decoded = StatusCheckParamsType.decode(rawParams); + let filterString: string = ''; let params: StatusCheckParams; if (isRight(atomicDecoded)) { const { filters, search, numTimes, timerangeCount, timerangeUnit } = atomicDecoded.right; const timerange = { from: `now-${String(timerangeCount) + timerangeUnit}`, to: 'now' }; - const filterString = JSON.stringify( - await genFilterString( - () => - libs.requests.getIndexPattern({ - callES: options.services.callCluster, - dynamicSettings, - }), - filters, - search - ) - ); + filterString = await formatFilterString(libs, dynamicSettings, options, filters, search); params = { timerange, numTimes, @@ -263,25 +379,49 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = (_server, libs) = }; } else if (isRight(decoded)) { params = decoded.right; - } else { + } else if (!isRight(availabilityDecoded)) { ThrowReporter.report(decoded); return { error: 'Alert param types do not conform to required shape.', }; } + let availabilityResults: GetMonitorAvailabilityResult[] = []; + if ( + isRight(availabilityDecoded) && + availabilityDecoded.right.shouldCheckAvailability === true + ) { + const { filters, search } = availabilityDecoded.right; + if (filterString === '' && (filters || search)) { + filterString = await formatFilterString(libs, dynamicSettings, options, filters, search); + } + + availabilityResults = await libs.requests.getMonitorAvailability({ + callES: options.services.callCluster, + dynamicSettings, + ...availabilityDecoded.right.availability, + filters: filterString || undefined, + }); + } + /* This is called `monitorsByLocation` but it's really * monitors by location by status. The query we run to generate this * filters on the status field, so effectively there should be one and only one * status represented in the result set. */ - const monitorsByLocation = await libs.requests.getMonitorStatus({ - callES: options.services.callCluster, - dynamicSettings, - ...params, - }); + let monitorsByLocation: GetMonitorStatusResult[] = []; + + // old alert versions are missing this field so it must default to true + const verifiedParams = StatusCheckParamsType.decode(params!); + if (isRight(verifiedParams) && (verifiedParams.right?.shouldCheckStatus ?? true)) { + monitorsByLocation = await libs.requests.getMonitorStatus({ + callES: options.services.callCluster, + dynamicSettings, + ...verifiedParams.right, + }); + } // if no monitors are down for our query, we don't need to trigger an alert - if (monitorsByLocation.length) { + if (monitorsByLocation.length || availabilityResults.length) { const uniqueIds = uniqueMonitorIds(monitorsByLocation); const alertInstance = options.services.alertInstanceFactory(MONITOR_STATUS.id); alertInstance.replaceState({ @@ -290,7 +430,14 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = (_server, libs) = ...updateState(options.state, true), }); alertInstance.scheduleActions(MONITOR_STATUS.id, { - message: contextMessage(Array.from(uniqueIds.keys()), DEFAULT_MAX_MESSAGE_ROWS), + message: contextMessage( + Array.from(uniqueIds.keys()), + DEFAULT_MAX_MESSAGE_ROWS, + availabilityResults, + isRight(availabilityDecoded) ? availabilityDecoded.right.availability.threshold : '100', + isRight(availabilityDecoded) && availabilityDecoded.right.shouldCheckAvailability, + rawParams?.shouldCheckStatus ?? false + ), downMonitorsWithGeo: fullListByIdAndLocation(monitorsByLocation), }); } diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_availability.test.ts b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_availability.test.ts new file mode 100644 index 00000000000000..e014aa985a82d1 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_availability.test.ts @@ -0,0 +1,853 @@ +/* + * 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 { + formatBuckets, + GetMonitorAvailabilityResult, + AvailabilityKey, + getMonitorAvailability, +} from '../get_monitor_availability'; +import { setupMockEsCompositeQuery } from './helper'; +import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../../common/constants'; +import { GetMonitorAvailabilityParams } from '../../../../common/runtime_types'; +interface AvailabilityTopHit { + _source: { + monitor: { + name: string; + }; + url: { + full: string; + }; + }; +} + +interface AvailabilityDoc { + key: AvailabilityKey; + doc_count: number; + up_sum: { + value: number; + }; + down_sum: { + value: number; + }; + fields: { + hits: { + hits: AvailabilityTopHit[]; + }; + }; + ratio: { + value: number | null; + }; +} + +const genBucketItem = ({ + monitorId, + location, + name, + url, + up, + down, + availabilityRatio, +}: GetMonitorAvailabilityResult): AvailabilityDoc => ({ + key: { + monitorId, + location, + }, + doc_count: up + down, + fields: { + hits: { + hits: [ + { + _source: { + monitor: { + name, + }, + url: { + full: url, + }, + }, + }, + ], + }, + }, + up_sum: { + value: up, + }, + down_sum: { + value: down, + }, + ratio: { + value: availabilityRatio, + }, +}); + +describe('monitor availability', () => { + describe('getMonitorAvailability', () => { + it('applies bool filters to params', async () => { + const [callES, esMock] = setupMockEsCompositeQuery< + AvailabilityKey, + GetMonitorAvailabilityResult, + AvailabilityDoc + >([], genBucketItem); + const exampleFilter = `{ + "bool": { + "should": [ + { + "bool": { + "should": [ + { + "match_phrase": { + "monitor.id": "apm-dev" + } + } + ], + "minimum_should_match": 1 + } + }, + { + "bool": { + "should": [ + { + "match_phrase": { + "monitor.id": "auto-http-0X8D6082B94BBE3B8A" + } + } + ], + "minimum_should_match": 1 + } + } + ], + "minimum_should_match": 1 + } + }`; + await getMonitorAvailability({ + callES, + dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, + filters: exampleFilter, + range: 2, + rangeUnit: 'w', + threshold: '54', + }); + expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1); + const [method, params] = esMock.callAsCurrentUser.mock.calls[0]; + expect(method).toEqual('search'); + expect(params).toMatchInlineSnapshot(` + Object { + "body": Object { + "aggs": Object { + "monitors": Object { + "aggs": Object { + "down_sum": Object { + "sum": Object { + "field": "summary.down", + "missing": 0, + }, + }, + "fields": Object { + "top_hits": Object { + "_source": Array [ + "monitor.name", + "url.full", + ], + "size": 1, + "sort": Array [ + Object { + "@timestamp": Object { + "order": "desc", + }, + }, + ], + }, + }, + "filtered": Object { + "bucket_selector": Object { + "buckets_path": Object { + "threshold": "ratio.value", + }, + "script": "params.threshold < 0.54", + }, + }, + "ratio": Object { + "bucket_script": Object { + "buckets_path": Object { + "downTotal": "down_sum", + "upTotal": "up_sum", + }, + "script": " + if (params.upTotal + params.downTotal > 0) { + return params.upTotal / (params.upTotal + params.downTotal); + } return null;", + }, + }, + "up_sum": Object { + "sum": Object { + "field": "summary.up", + "missing": 0, + }, + }, + }, + "composite": Object { + "size": 2000, + "sources": Array [ + Object { + "monitorId": Object { + "terms": Object { + "field": "monitor.id", + }, + }, + }, + Object { + "location": Object { + "terms": Object { + "field": "observer.geo.name", + "missing_bucket": true, + }, + }, + }, + ], + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-2w", + "lte": "now", + }, + }, + }, + ], + "minimum_should_match": 1, + "should": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "monitor.id": "apm-dev", + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "monitor.id": "auto-http-0X8D6082B94BBE3B8A", + }, + }, + ], + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "heartbeat-8*", + } + `); + }); + + it('fetches a single page of results', async () => { + const [callES, esMock] = setupMockEsCompositeQuery< + AvailabilityKey, + GetMonitorAvailabilityResult, + AvailabilityDoc + >( + [ + { + bucketCriteria: [ + { + monitorId: 'foo', + location: 'harrisburg', + name: 'Foo', + url: 'http://foo.com', + up: 456, + down: 234, + availabilityRatio: 0.660869565217391, + }, + { + monitorId: 'foo', + location: 'faribanks', + name: 'Foo', + url: 'http://foo.com', + up: 450, + down: 240, + availabilityRatio: 0.652173913043478, + }, + { + monitorId: 'bar', + location: 'fairbanks', + name: 'Bar', + url: 'http://bar.com', + up: 468, + down: 212, + availabilityRatio: 0.688235294117647, + }, + ], + }, + ], + genBucketItem + ); + const clientParameters: GetMonitorAvailabilityParams = { + range: 23, + rangeUnit: 'd', + threshold: '69', + }; + const result = await getMonitorAvailability({ + callES, + dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, + ...clientParameters, + }); + expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1); + const [method, params] = esMock.callAsCurrentUser.mock.calls[0]; + expect(method).toEqual('search'); + expect(params).toMatchInlineSnapshot(` + Object { + "body": Object { + "aggs": Object { + "monitors": Object { + "aggs": Object { + "down_sum": Object { + "sum": Object { + "field": "summary.down", + "missing": 0, + }, + }, + "fields": Object { + "top_hits": Object { + "_source": Array [ + "monitor.name", + "url.full", + ], + "size": 1, + "sort": Array [ + Object { + "@timestamp": Object { + "order": "desc", + }, + }, + ], + }, + }, + "filtered": Object { + "bucket_selector": Object { + "buckets_path": Object { + "threshold": "ratio.value", + }, + "script": "params.threshold < 0.69", + }, + }, + "ratio": Object { + "bucket_script": Object { + "buckets_path": Object { + "downTotal": "down_sum", + "upTotal": "up_sum", + }, + "script": " + if (params.upTotal + params.downTotal > 0) { + return params.upTotal / (params.upTotal + params.downTotal); + } return null;", + }, + }, + "up_sum": Object { + "sum": Object { + "field": "summary.up", + "missing": 0, + }, + }, + }, + "composite": Object { + "size": 2000, + "sources": Array [ + Object { + "monitorId": Object { + "terms": Object { + "field": "monitor.id", + }, + }, + }, + Object { + "location": Object { + "terms": Object { + "field": "observer.geo.name", + "missing_bucket": true, + }, + }, + }, + ], + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-23d", + "lte": "now", + }, + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "heartbeat-8*", + } + `); + + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "availabilityRatio": 0.660869565217391, + "down": 234, + "location": "harrisburg", + "monitorId": "foo", + "name": "Foo", + "up": 456, + "url": "http://foo.com", + }, + Object { + "availabilityRatio": 0.652173913043478, + "down": 240, + "location": "faribanks", + "monitorId": "foo", + "name": "Foo", + "up": 450, + "url": "http://foo.com", + }, + Object { + "availabilityRatio": 0.688235294117647, + "down": 212, + "location": "fairbanks", + "monitorId": "bar", + "name": "Bar", + "up": 468, + "url": "http://bar.com", + }, + ] + `); + }); + + it('fetches multiple pages', async () => { + const [callES, esMock] = setupMockEsCompositeQuery< + AvailabilityKey, + GetMonitorAvailabilityResult, + AvailabilityDoc + >( + [ + { + after_key: { + monitorId: 'baz', + location: 'harrisburg', + }, + bucketCriteria: [ + { + monitorId: 'foo', + location: 'harrisburg', + name: 'Foo', + url: 'http://foo.com', + up: 243, + down: 11, + availabilityRatio: 0.956692913385827, + }, + { + monitorId: 'foo', + location: 'fairbanks', + name: 'Foo', + url: 'http://foo.com', + up: 251, + down: 13, + availabilityRatio: 0.950757575757576, + }, + ], + }, + { + bucketCriteria: [ + { + monitorId: 'baz', + location: 'harrisburg', + name: 'Baz', + url: 'http://baz.com', + up: 341, + down: 3, + availabilityRatio: 0.991279069767442, + }, + { + monitorId: 'baz', + location: 'fairbanks', + name: 'Baz', + url: 'http://baz.com', + up: 365, + down: 5, + availabilityRatio: 0.986486486486486, + }, + ], + }, + ], + genBucketItem + ); + const result = await getMonitorAvailability({ + callES, + dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, + range: 3, + rangeUnit: 'M', + threshold: '98', + }); + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "availabilityRatio": 0.956692913385827, + "down": 11, + "location": "harrisburg", + "monitorId": "foo", + "name": "Foo", + "up": 243, + "url": "http://foo.com", + }, + Object { + "availabilityRatio": 0.950757575757576, + "down": 13, + "location": "fairbanks", + "monitorId": "foo", + "name": "Foo", + "up": 251, + "url": "http://foo.com", + }, + Object { + "availabilityRatio": 0.991279069767442, + "down": 3, + "location": "harrisburg", + "monitorId": "baz", + "name": "Baz", + "up": 341, + "url": "http://baz.com", + }, + Object { + "availabilityRatio": 0.986486486486486, + "down": 5, + "location": "fairbanks", + "monitorId": "baz", + "name": "Baz", + "up": 365, + "url": "http://baz.com", + }, + ] + `); + const [method, params] = esMock.callAsCurrentUser.mock.calls[0]; + expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(2); + expect(method).toEqual('search'); + expect(params).toMatchInlineSnapshot(` + Object { + "body": Object { + "aggs": Object { + "monitors": Object { + "aggs": Object { + "down_sum": Object { + "sum": Object { + "field": "summary.down", + "missing": 0, + }, + }, + "fields": Object { + "top_hits": Object { + "_source": Array [ + "monitor.name", + "url.full", + ], + "size": 1, + "sort": Array [ + Object { + "@timestamp": Object { + "order": "desc", + }, + }, + ], + }, + }, + "filtered": Object { + "bucket_selector": Object { + "buckets_path": Object { + "threshold": "ratio.value", + }, + "script": "params.threshold < 0.98", + }, + }, + "ratio": Object { + "bucket_script": Object { + "buckets_path": Object { + "downTotal": "down_sum", + "upTotal": "up_sum", + }, + "script": " + if (params.upTotal + params.downTotal > 0) { + return params.upTotal / (params.upTotal + params.downTotal); + } return null;", + }, + }, + "up_sum": Object { + "sum": Object { + "field": "summary.up", + "missing": 0, + }, + }, + }, + "composite": Object { + "size": 2000, + "sources": Array [ + Object { + "monitorId": Object { + "terms": Object { + "field": "monitor.id", + }, + }, + }, + Object { + "location": Object { + "terms": Object { + "field": "observer.geo.name", + "missing_bucket": true, + }, + }, + }, + ], + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-3M", + "lte": "now", + }, + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "heartbeat-8*", + } + `); + expect(esMock.callAsCurrentUser.mock.calls[1]).toMatchInlineSnapshot(` + Array [ + "search", + Object { + "body": Object { + "aggs": Object { + "monitors": Object { + "aggs": Object { + "down_sum": Object { + "sum": Object { + "field": "summary.down", + "missing": 0, + }, + }, + "fields": Object { + "top_hits": Object { + "_source": Array [ + "monitor.name", + "url.full", + ], + "size": 1, + "sort": Array [ + Object { + "@timestamp": Object { + "order": "desc", + }, + }, + ], + }, + }, + "filtered": Object { + "bucket_selector": Object { + "buckets_path": Object { + "threshold": "ratio.value", + }, + "script": "params.threshold < 0.98", + }, + }, + "ratio": Object { + "bucket_script": Object { + "buckets_path": Object { + "downTotal": "down_sum", + "upTotal": "up_sum", + }, + "script": " + if (params.upTotal + params.downTotal > 0) { + return params.upTotal / (params.upTotal + params.downTotal); + } return null;", + }, + }, + "up_sum": Object { + "sum": Object { + "field": "summary.up", + "missing": 0, + }, + }, + }, + "composite": Object { + "after": Object { + "location": "harrisburg", + "monitorId": "baz", + }, + "size": 2000, + "sources": Array [ + Object { + "monitorId": Object { + "terms": Object { + "field": "monitor.id", + }, + }, + }, + Object { + "location": Object { + "terms": Object { + "field": "observer.geo.name", + "missing_bucket": true, + }, + }, + }, + ], + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-3M", + "lte": "now", + }, + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "heartbeat-8*", + }, + ] + `); + }); + }); + + describe('formatBuckets', () => { + let buckets: any[]; + + beforeEach(() => { + buckets = [ + { + key: { + monitorId: 'test-node-service', + location: 'fairbanks', + }, + doc_count: 3271, + fields: { + hits: { + hits: [ + { + _source: { + monitor: { + name: 'Test Node Service', + }, + url: { + full: 'http://localhost:12349', + }, + }, + }, + ], + }, + }, + up_sum: { + value: 821.0, + }, + down_sum: { + value: 2450.0, + }, + ratio: { + value: 0.25099357994497096, + }, + }, + { + key: { + monitorId: 'test-node-service', + location: 'harrisburg', + }, + fields: { + hits: { + hits: [ + { + _source: { + monitor: { + name: 'Test Node Service', + }, + url: { + full: 'http://localhost:12349', + }, + }, + }, + ], + }, + }, + doc_count: 5839, + up_sum: { + value: 3389.0, + }, + down_sum: { + value: 2450.0, + }, + ratio: { + value: 0.5804076040417879, + }, + }, + ]; + }); + + it('formats the buckets to the correct shape', async () => { + expect(await formatBuckets(buckets)).toMatchInlineSnapshot(` + Array [ + Object { + "availabilityRatio": 0.25099357994497096, + "down": 2450, + "location": "fairbanks", + "monitorId": "test-node-service", + "name": "Test Node Service", + "up": 821, + "url": "http://localhost:12349", + }, + Object { + "availabilityRatio": 0.5804076040417879, + "down": 2450, + "location": "harrisburg", + "monitorId": "test-node-service", + "name": "Test Node Service", + "up": 3389, + "url": "http://localhost:12349", + }, + ] + `); + }); + }); +}); diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts index 2a1417b49dca41..1783cb3c28522d 100644 --- a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts @@ -4,12 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { elasticsearchServiceMock } from '../../../../../../../src/core/server/mocks'; import { getMonitorStatus } from '../get_monitor_status'; -import { LegacyScopedClusterClient } from 'src/core/server'; import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../../common/constants'; +import { setupMockEsCompositeQuery } from './helper'; -interface BucketItemCriteria { +export interface BucketItemCriteria { monitor_id: string; status: string; location: string; @@ -27,11 +26,6 @@ interface BucketItem { doc_count: number; } -interface MultiPageCriteria { - after_key?: BucketKey; - bucketCriteria: BucketItemCriteria[]; -} - const genBucketItem = ({ monitor_id, status, @@ -46,30 +40,12 @@ const genBucketItem = ({ doc_count, }); -type MockCallES = (method: any, params: any) => Promise; - -const setupMock = ( - criteria: MultiPageCriteria[] -): [MockCallES, jest.Mocked>] => { - const esMock = elasticsearchServiceMock.createLegacyScopedClusterClient(); - - criteria.forEach(({ after_key, bucketCriteria }) => { - const mockResponse = { - aggregations: { - monitors: { - after_key, - buckets: bucketCriteria.map((item) => genBucketItem(item)), - }, - }, - }; - esMock.callAsCurrentUser.mockResolvedValueOnce(mockResponse); - }); - return [(method: any, params: any) => esMock.callAsCurrentUser(method, params), esMock]; -}; - describe('getMonitorStatus', () => { it('applies bool filters to params', async () => { - const [callES, esMock] = setupMock([]); + const [callES, esMock] = setupMockEsCompositeQuery( + [], + genBucketItem + ); const exampleFilter = `{ "bool": { "should": [ @@ -203,7 +179,10 @@ describe('getMonitorStatus', () => { }); it('applies locations to params', async () => { - const [callES, esMock] = setupMock([]); + const [callES, esMock] = setupMockEsCompositeQuery( + [], + genBucketItem + ); await getMonitorStatus({ callES, dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, @@ -294,30 +273,33 @@ describe('getMonitorStatus', () => { }); it('fetches single page of results', async () => { - const [callES, esMock] = setupMock([ - { - bucketCriteria: [ - { - monitor_id: 'foo', - status: 'down', - location: 'fairbanks', - doc_count: 43, - }, - { - monitor_id: 'bar', - status: 'down', - location: 'harrisburg', - doc_count: 53, - }, - { - monitor_id: 'foo', - status: 'down', - location: 'harrisburg', - doc_count: 44, - }, - ], - }, - ]); + const [callES, esMock] = setupMockEsCompositeQuery( + [ + { + bucketCriteria: [ + { + monitor_id: 'foo', + status: 'down', + location: 'fairbanks', + doc_count: 43, + }, + { + monitor_id: 'bar', + status: 'down', + location: 'harrisburg', + doc_count: 53, + }, + { + monitor_id: 'foo', + status: 'down', + location: 'harrisburg', + doc_count: 44, + }, + ], + }, + ], + genBucketItem + ); const clientParameters = { filters: undefined, locations: [], @@ -418,7 +400,7 @@ describe('getMonitorStatus', () => { `); }); - it('fetches multiple pages of results in the thing', async () => { + it('fetches multiple pages of ES results', async () => { const criteria = [ { after_key: { @@ -491,7 +473,10 @@ describe('getMonitorStatus', () => { ], }, ]; - const [callES] = setupMock(criteria); + const [callES] = setupMockEsCompositeQuery( + criteria, + genBucketItem + ); const result = await getMonitorStatus({ callES, dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/helper.ts b/x-pack/plugins/uptime/server/lib/requests/__tests__/helper.ts new file mode 100644 index 00000000000000..0eb46e17c63241 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/helper.ts @@ -0,0 +1,49 @@ +/* + * 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 { LegacyScopedClusterClient } from 'src/core/server'; +import { elasticsearchServiceMock } from '../../../../../../../src/core/server/mocks'; + +export interface MultiPageCriteria { + after_key?: K; + bucketCriteria: T[]; +} + +export type MockCallES = (method: any, params: any) => Promise; + +/** + * This utility function will set up a mock ES client, and store subsequent calls. It is designed + * to let callers easily simulate an arbitrary series of chained composite aggregation calls by supplying + * custom after_key values. + * + * This function is used by supplying criteria, a flat collection of values, and a function that can map + * those values to the same document shape the tested code expects to receive from elasticsearch. + * @param criteria A series of objects with the fields of interest. + * @param genBucketItem A function that maps the criteria to the structure of a document. + * @template K The Key type of the mock after_key value for simulated composite aggregation queries. + * @template C The Criteria type that specifies the values of interest in the buckets returned by the mock ES. + * @template I The Item type that specifies the simulated documents that are generated by the mock. + */ +export const setupMockEsCompositeQuery = ( + criteria: Array>, + genBucketItem: (criteria: C) => I +): [MockCallES, jest.Mocked>] => { + const esMock = elasticsearchServiceMock.createLegacyScopedClusterClient(); + + criteria.forEach(({ after_key, bucketCriteria }) => { + const mockResponse = { + aggregations: { + monitors: { + after_key, + buckets: bucketCriteria.map((item) => genBucketItem(item)), + }, + }, + }; + esMock.callAsCurrentUser.mockResolvedValueOnce(mockResponse); + }); + + return [(method: any, params: any) => esMock.callAsCurrentUser(method, params), esMock]; +}; diff --git a/x-pack/plugins/uptime/server/lib/requests/get_monitor_availability.ts b/x-pack/plugins/uptime/server/lib/requests/get_monitor_availability.ts new file mode 100644 index 00000000000000..eafc0df431f770 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/get_monitor_availability.ts @@ -0,0 +1,160 @@ +/* + * 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 { UMElasticsearchQueryFn } from '../adapters'; +import { GetMonitorAvailabilityParams } from '../../../common/runtime_types'; + +export interface AvailabilityKey { + monitorId: string; + location: string; +} + +export interface GetMonitorAvailabilityResult { + monitorId: string; + location: string; + name: string; + url: string; + up: number; + down: number; + availabilityRatio: number | null; +} + +export const formatBuckets = async (buckets: any[]): Promise => + buckets.map(({ key, fields, up_sum, down_sum, ratio }: any) => ({ + ...key, + name: fields?.hits?.hits?.[0]?._source?.monitor.name, + url: fields?.hits?.hits?.[0]?._source?.url.full, + up: up_sum.value, + down: down_sum.value, + availabilityRatio: ratio.value, + })); + +export const getMonitorAvailability: UMElasticsearchQueryFn< + GetMonitorAvailabilityParams, + GetMonitorAvailabilityResult[] +> = async ({ callES, dynamicSettings, range, rangeUnit, threshold: thresholdString, filters }) => { + const queryResults: Array> = []; + let afterKey: AvailabilityKey | undefined; + + const threshold = Number(thresholdString) / 100; + if (threshold <= 0 || threshold > 1.0) { + throw new Error( + `Invalid availability threshold value ${thresholdString}. The value must be between 0 and 100` + ); + } + + const gte = `now-${range}${rangeUnit}`; + + do { + const esParams: any = { + index: dynamicSettings.heartbeatIndices, + body: { + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte, + lte: 'now', + }, + }, + }, + ], + }, + }, + size: 0, + aggs: { + monitors: { + composite: { + size: 2000, + sources: [ + { + monitorId: { + terms: { + field: 'monitor.id', + }, + }, + }, + { + location: { + terms: { + field: 'observer.geo.name', + missing_bucket: true, + }, + }, + }, + ], + }, + aggs: { + fields: { + top_hits: { + size: 1, + _source: ['monitor.name', 'url.full'], + sort: [ + { + '@timestamp': { + order: 'desc', + }, + }, + ], + }, + }, + up_sum: { + sum: { + field: 'summary.up', + missing: 0, + }, + }, + down_sum: { + sum: { + field: 'summary.down', + missing: 0, + }, + }, + ratio: { + bucket_script: { + buckets_path: { + upTotal: 'up_sum', + downTotal: 'down_sum', + }, + script: ` + if (params.upTotal + params.downTotal > 0) { + return params.upTotal / (params.upTotal + params.downTotal); + } return null;`, + }, + }, + filtered: { + bucket_selector: { + buckets_path: { + threshold: 'ratio.value', + }, + script: `params.threshold < ${threshold}`, + }, + }, + }, + }, + }, + }, + }; + + if (filters) { + const parsedFilters = JSON.parse(filters); + esParams.body.query.bool = { ...esParams.body.query.bool, ...parsedFilters.bool }; + } + + if (afterKey) { + esParams.body.aggs.monitors.composite.after = afterKey; + } + + const result = await callES('search', esParams); + afterKey = result?.aggregations?.monitors?.after_key; + + queryResults.push(formatBuckets(result?.aggregations?.monitors?.buckets || [])); + } while (afterKey !== undefined); + + return (await Promise.all(queryResults)).reduce((acc, cur) => acc.concat(cur), []); +}; diff --git a/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts b/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts index 8435240963ebfa..33f18b7a940694 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts @@ -54,10 +54,10 @@ export const getMonitorStatus: UMElasticsearchQueryFn< const queryResults: Array> = []; let afterKey: MonitorStatusKey | undefined; + const STATUS = 'down'; do { // today this value is hardcoded. In the future we may support // multiple status types for this alert, and this will become a parameter - const STATUS = 'down'; const esParams: any = { index: dynamicSettings.heartbeatIndices, body: { diff --git a/x-pack/plugins/uptime/server/lib/requests/index.ts b/x-pack/plugins/uptime/server/lib/requests/index.ts index 243bb089cc7b46..415b3d2f4b4a17 100644 --- a/x-pack/plugins/uptime/server/lib/requests/index.ts +++ b/x-pack/plugins/uptime/server/lib/requests/index.ts @@ -8,6 +8,7 @@ export { getCerts } from './get_certs'; export { getFilterBar, GetFilterBarParams } from './get_filter_bar'; export { getUptimeIndexPattern as getIndexPattern } from './get_index_pattern'; export { getLatestMonitor, GetLatestMonitorParams } from './get_latest_monitor'; +export { getMonitorAvailability } from './get_monitor_availability'; export { getMonitorDurationChart, GetMonitorChartsParams } from './get_monitor_duration'; export { getMonitorDetails, GetMonitorDetailsParams } from './get_monitor_details'; export { getMonitorLocations, GetMonitorLocationsParams } from './get_monitor_locations'; diff --git a/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts b/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts index ae3a729e41c706..2a9420a2755709 100644 --- a/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts +++ b/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts @@ -7,6 +7,7 @@ import { UMElasticsearchQueryFn } from '../adapters'; import { OverviewFilters, + GetMonitorAvailabilityParams, MonitorDetails, MonitorLocations, Snapshot, @@ -34,6 +35,7 @@ import { } from '.'; import { GetSnapshotCountParams } from './get_snapshot_counts'; import { IIndexPattern } from '../../../../../../src/plugins/data/server'; +import { GetMonitorAvailabilityResult } from './get_monitor_availability'; type ESQ = UMElasticsearchQueryFn; @@ -42,6 +44,7 @@ export interface UptimeRequests { getFilterBar: ESQ; getIndexPattern: ESQ<{}, IIndexPattern | undefined>; getLatestMonitor: ESQ; + getMonitorAvailability: ESQ; getMonitorDurationChart: ESQ; getMonitorDetails: ESQ; getMonitorLocations: ESQ; From d58f52de2bc793d61b9755961bccbf475ed1033f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Thu, 9 Jul 2020 21:31:53 +0200 Subject: [PATCH 16/49] [Composable template] Demo and PR review fixes (#71065) --- .../static/forms/components/form_row.tsx | 6 +- .../home/index_templates_tab.helpers.ts | 4 +- .../home/index_templates_tab.test.ts | 70 ++-- .../template_create.test.tsx | 2 +- .../template_edit.test.tsx | 2 +- .../common/lib/template_serialization.ts | 26 +- .../index_management/common/lib/utils.ts | 2 +- .../common/types/templates.ts | 18 +- .../component_template_create.test.tsx | 2 - .../component_template_edit.test.tsx | 5 - .../component_templates.tsx | 56 ++-- .../component_templates_selector.scss | 4 + .../component_templates_selector.tsx | 6 +- .../steps/step_logistics.tsx | 8 +- .../steps/step_logistics_schema.tsx | 2 +- .../configuration_form/configuration_form.tsx | 26 +- .../configuration_form_schema.tsx | 22 +- .../templates_form/templates_form.tsx | 14 +- .../components/mappings_editor/lib/utils.ts | 2 +- .../mappings_editor/mappings_editor.tsx | 6 +- .../mappings_editor/mappings_state.tsx | 13 +- .../template_form/steps/step_components.tsx | 19 +- .../template_form/steps/step_logistics.tsx | 221 +++++++++---- .../template_form/steps/step_review.tsx | 2 +- .../template_form/template_form.tsx | 68 ++-- .../template_form/template_form_schemas.tsx | 34 ++ .../home/template_list/components/index.ts | 2 + .../components/template_type_indicator.tsx | 37 +++ .../template_table/template_table.tsx | 95 +++--- .../template_details/tabs/tab_summary.tsx | 307 +++++++++--------- .../template_details_content.tsx | 17 +- .../home/template_list/template_list.tsx | 48 ++- .../template_table/template_table.tsx | 39 +-- .../sections/template_edit/template_edit.tsx | 4 +- .../index_management/public/shared_imports.ts | 3 + .../routes/api/templates/validate_schemas.ts | 4 +- .../test/fixtures/template.ts | 52 +-- .../index_management/templates.helpers.js | 8 +- 38 files changed, 756 insertions(+), 500 deletions(-) create mode 100644 x-pack/plugins/index_management/public/application/sections/home/template_list/components/template_type_indicator.tsx diff --git a/src/plugins/es_ui_shared/static/forms/components/form_row.tsx b/src/plugins/es_ui_shared/static/forms/components/form_row.tsx index ad5a517e40cfbd..d38e6c4f5fd95c 100644 --- a/src/plugins/es_ui_shared/static/forms/components/form_row.tsx +++ b/src/plugins/es_ui_shared/static/forms/components/form_row.tsx @@ -57,13 +57,9 @@ export const FormRow = ({ titleWrapped = title; } - if (!children && !field) { - throw new Error('You need to provide either children or a field to the FormRow'); - } - return ( - {children ? children : } + {children ? children : field ? : null} ); }; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts index 0047e4c0294cb9..a3974190533517 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts @@ -95,9 +95,9 @@ const createActions = (testBed: TestBed) => { find('closeDetailsButton').simulate('click'); }; - const toggleViewItem = (view: 'composable' | 'system') => { + const toggleViewItem = (view: 'managed' | 'cloudManaged' | 'system') => { const { find, component } = testBed; - const views = ['composable', 'system']; + const views = ['managed', 'cloudManaged', 'system']; // First open the pop over act(() => { diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts index 1ec29f1c5b894b..276101486aa619 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts @@ -63,7 +63,6 @@ describe('Index Templates tab', () => { }, }, }); - (template1 as any).hasSettings = true; const template2 = fixtures.getTemplate({ name: `b${getRandomString()}`, @@ -73,6 +72,7 @@ describe('Index Templates tab', () => { const template3 = fixtures.getTemplate({ name: `.c${getRandomString()}`, // mock system template indexPatterns: ['template3Pattern1*', 'template3Pattern2', 'template3Pattern3'], + type: 'system', }); const template4 = fixtures.getTemplate({ @@ -101,6 +101,7 @@ describe('Index Templates tab', () => { name: `.c${getRandomString()}`, // mock system template indexPatterns: ['template6Pattern1*', 'template6Pattern2', 'template6Pattern3'], isLegacy: true, + type: 'system', }); const templates = [template1, template2, template3]; @@ -124,44 +125,49 @@ describe('Index Templates tab', () => { // Test composable table content tableCellsValues.forEach((row, i) => { const indexTemplate = templates[i]; - const { name, indexPatterns, priority, ilmPolicy, composedOf, template } = indexTemplate; + const { name, indexPatterns, ilmPolicy, composedOf, template } = indexTemplate; const hasContent = !!template.settings || !!template.mappings || !!template.aliases; const ilmPolicyName = ilmPolicy && ilmPolicy.name ? ilmPolicy.name : ''; const composedOfString = composedOf ? composedOf.join(',') : ''; - const priorityFormatted = priority ? priority.toString() : ''; - - expect(removeWhiteSpaceOnArrayValues(row)).toEqual([ - '', // Checkbox to select row - name, - indexPatterns.join(', '), - ilmPolicyName, - composedOfString, - priorityFormatted, - hasContent ? 'M S A' : 'None', // M S A -> Mappings Settings Aliases badges - '', // Column of actions - ]); + + try { + expect(removeWhiteSpaceOnArrayValues(row)).toEqual([ + '', // Checkbox to select row + name, + indexPatterns.join(', '), + ilmPolicyName, + composedOfString, + hasContent ? 'M S A' : 'None', // M S A -> Mappings Settings Aliases badges + '', // Column of actions + ]); + } catch (e) { + console.error(`Error in index template at row ${i}`); // eslint-disable-line no-console + throw e; + } }); // Test legacy table content legacyTableCellsValues.forEach((row, i) => { - const template = legacyTemplates[i]; - const { name, indexPatterns, order, ilmPolicy } = template; + const legacyIndexTemplate = legacyTemplates[i]; + const { name, indexPatterns, ilmPolicy, template } = legacyIndexTemplate; + const hasContent = !!template.settings || !!template.mappings || !!template.aliases; const ilmPolicyName = ilmPolicy && ilmPolicy.name ? ilmPolicy.name : ''; - const orderFormatted = order ? order.toString() : order; - - expect(removeWhiteSpaceOnArrayValues(row)).toEqual([ - '', - name, - indexPatterns.join(', '), - ilmPolicyName, - orderFormatted, - '', - '', - '', - '', - ]); + + try { + expect(removeWhiteSpaceOnArrayValues(row)).toEqual([ + '', + name, + indexPatterns.join(', '), + ilmPolicyName, + hasContent ? 'M S A' : 'None', // M S A -> Mappings Settings Aliases badges + '', // Column of actions + ]); + } catch (e) { + console.error(`Error in legacy template at row ${i}`); // eslint-disable-line no-console + throw e; + } }); }); @@ -211,7 +217,7 @@ describe('Index Templates tab', () => { await actions.clickTemplateAt(0); expect(exists('templateList')).toBe(true); expect(exists('templateDetails')).toBe(true); - expect(find('templateDetails.title').text()).toBe(templates[0].name); + expect(find('templateDetails.title').text().trim()).toBe(templates[0].name); // Close flyout await act(async () => { @@ -223,7 +229,7 @@ describe('Index Templates tab', () => { expect(exists('templateList')).toBe(true); expect(exists('templateDetails')).toBe(true); - expect(find('templateDetails.title').text()).toBe(legacyTemplates[0].name); + expect(find('templateDetails.title').text().trim()).toBe(legacyTemplates[0].name); }); describe('table row actions', () => { @@ -460,7 +466,7 @@ describe('Index Templates tab', () => { const { find } = testBed; const [{ name }] = templates; - expect(find('templateDetails.title').text()).toEqual(name); + expect(find('templateDetails.title').text().trim()).toEqual(name); }); it('should have a close button and be able to close flyout', async () => { diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx index 69d7a13edfcfbe..76b6c34f999d5b 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx @@ -368,8 +368,8 @@ describe.skip('', () => { aliases: ALIASES, }, _kbnMeta: { + type: 'default', isLegacy: false, - isManaged: false, }, }; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx index 9f0e81454f0af0..de66013241236b 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx @@ -213,7 +213,7 @@ describe.skip('', () => { aliases: ALIASES, }, _kbnMeta: { - isManaged: false, + type: 'default', isLegacy: templateToEdit._kbnMeta.isLegacy, }, }; diff --git a/x-pack/plugins/index_management/common/lib/template_serialization.ts b/x-pack/plugins/index_management/common/lib/template_serialization.ts index 5c55860bda81b2..069d6ac29fbca7 100644 --- a/x-pack/plugins/index_management/common/lib/template_serialization.ts +++ b/x-pack/plugins/index_management/common/lib/template_serialization.ts @@ -8,18 +8,28 @@ import { LegacyTemplateSerialized, TemplateSerialized, TemplateListItem, + TemplateType, } from '../types'; const hasEntries = (data: object = {}) => Object.entries(data).length > 0; export function serializeTemplate(templateDeserialized: TemplateDeserialized): TemplateSerialized { - const { version, priority, indexPatterns, template, composedOf, _meta } = templateDeserialized; + const { + version, + priority, + indexPatterns, + template, + composedOf, + dataStream, + _meta, + } = templateDeserialized; return { version, priority, template, index_patterns: indexPatterns, + data_stream: dataStream, composed_of: composedOf, _meta, }; @@ -41,6 +51,15 @@ export function deserializeTemplate( } = templateEs; const { settings } = template; + let type: TemplateType = 'default'; + if (Boolean(cloudManagedTemplatePrefix && name.startsWith(cloudManagedTemplatePrefix))) { + type = 'cloudManaged'; + } else if (name.startsWith('.')) { + type = 'system'; + } else if (Boolean(_meta?.managed === true)) { + type = 'managed'; + } + const deserializedTemplate: TemplateDeserialized = { name, version, @@ -52,10 +71,7 @@ export function deserializeTemplate( dataStream, _meta, _kbnMeta: { - isManaged: Boolean(_meta?.managed === true), - isCloudManaged: Boolean( - cloudManagedTemplatePrefix && name.startsWith(cloudManagedTemplatePrefix) - ), + type, hasDatastream: Boolean(dataStream), }, }; diff --git a/x-pack/plugins/index_management/common/lib/utils.ts b/x-pack/plugins/index_management/common/lib/utils.ts index 5a7db8ef50ab43..1dc6f4a486a2c1 100644 --- a/x-pack/plugins/index_management/common/lib/utils.ts +++ b/x-pack/plugins/index_management/common/lib/utils.ts @@ -23,5 +23,5 @@ export const getTemplateParameter = ( ) => { return isLegacyTemplate(template) ? (template as LegacyTemplateSerialized)[setting] - : (template as TemplateSerialized).template[setting]; + : (template as TemplateSerialized).template?.[setting]; }; diff --git a/x-pack/plugins/index_management/common/types/templates.ts b/x-pack/plugins/index_management/common/types/templates.ts index fdcac40ca596ff..32e254e490b2aa 100644 --- a/x-pack/plugins/index_management/common/types/templates.ts +++ b/x-pack/plugins/index_management/common/types/templates.ts @@ -38,23 +38,24 @@ export interface TemplateDeserialized { aliases?: Aliases; mappings?: Mappings; }; - composedOf?: string[]; // Used on composable index template + composedOf?: string[]; // Composable template only version?: number; - priority?: number; - order?: number; // Used on legacy index template + priority?: number; // Composable template only + order?: number; // Legacy template only ilmPolicy?: { name: string; }; - _meta?: { [key: string]: any }; - dataStream?: { timestamp_field: string }; + _meta?: { [key: string]: any }; // Composable template only + dataStream?: { timestamp_field: string }; // Composable template only _kbnMeta: { - isManaged: boolean; - isCloudManaged: boolean; + type: TemplateType; hasDatastream: boolean; isLegacy?: boolean; }; } +export type TemplateType = 'default' | 'managed' | 'cloudManaged' | 'system'; + export interface TemplateFromEs { name: string; index_template: TemplateSerialized; @@ -78,8 +79,7 @@ export interface TemplateListItem { name: string; }; _kbnMeta: { - isManaged: boolean; - isCloudManaged: boolean; + type: TemplateType; hasDatastream: boolean; isLegacy?: boolean; }; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx index 6c8da4684f019a..75eb419d56a5c9 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx @@ -177,8 +177,6 @@ describe('', () => { template: { settings: SETTINGS, mappings: { - _source: {}, - _meta: {}, properties: { [BOOLEAN_MAPPING_FIELD.name]: { type: BOOLEAN_MAPPING_FIELD.type, diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx index f237605756d5c3..115fdf032da8f0 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx @@ -109,11 +109,6 @@ describe('', () => { ...COMPONENT_TEMPLATE_TO_EDIT, template: { ...COMPONENT_TEMPLATE_TO_EDIT.template, - mappings: { - _meta: {}, - _source: {}, - properties: {}, - }, }, }; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates.tsx index 64c7cd400ba0d6..ea5632ac861924 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates.tsx @@ -26,8 +26,15 @@ interface Filters { [key: string]: { name: string; checked: 'on' | 'off' }; } +/** + * Copied from https://stackoverflow.com/a/9310752 + */ +function escapeRegExp(text: string) { + return text.replace(/[-\[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); +} + function fuzzyMatch(searchValue: string, text: string) { - const pattern = `.*${searchValue.split('').join('.*')}.*`; + const pattern = `.*${searchValue.split('').map(escapeRegExp).join('.*')}.*`; const regex = new RegExp(pattern); return regex.test(text); } @@ -48,7 +55,7 @@ const i18nTexts = { searchBoxPlaceholder: i18n.translate( 'xpack.idxMgmt.componentTemplatesSelector.searchBox.placeholder', { - defaultMessage: 'Search components', + defaultMessage: 'Search component templates', } ), }; @@ -78,24 +85,33 @@ export const ComponentTemplates = ({ isLoading, components, listItemProps }: Pro return []; } - return components.filter((component) => { - if (filters.settings.checked === 'on' && !component.hasSettings) { - return false; - } - if (filters.mappings.checked === 'on' && !component.hasMappings) { - return false; - } - if (filters.aliases.checked === 'on' && !component.hasAliases) { - return false; - } - - if (searchValue.trim() === '') { - return true; - } - - const match = fuzzyMatch(searchValue, component.name); - return match; - }); + return components + .filter((component) => { + if (filters.settings.checked === 'on' && !component.hasSettings) { + return false; + } + if (filters.mappings.checked === 'on' && !component.hasMappings) { + return false; + } + if (filters.aliases.checked === 'on' && !component.hasAliases) { + return false; + } + + if (searchValue.trim() === '') { + return true; + } + + const match = fuzzyMatch(searchValue, component.name); + return match; + }) + .sort((a, b) => { + if (a.name < b.name) { + return -1; + } else if (a.name > b.name) { + return 1; + } + return 0; + }); }, [isLoading, components, searchValue, filters]); const isSearchResultEmpty = filteredComponents.length === 0 && components.length > 0; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.scss b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.scss index 6abbbe65790e73..61d5512da2cd9f 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.scss +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.scss @@ -32,5 +32,9 @@ font-weight: 600; } } + + &__content { + mask-image: none; + } } } diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.tsx index af48c3c79379ad..8795c08fd2bee8 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.tsx @@ -96,7 +96,7 @@ export const ComponentTemplatesSelector = ({ ); @@ -136,7 +136,7 @@ export const ComponentTemplatesSelector = ({ }} /> -
+
)} diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx index 8762eae9d2297c..18988fa125a066 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx @@ -200,7 +200,7 @@ export const StepLogistics: React.FunctionComponent = React.memo( } > - {isMetaVisible ? ( + {isMetaVisible && ( = React.memo( 'aria-label': i18n.translate( 'xpack.idxMgmt.componentTemplateForm.stepLogistics.metaAriaLabel', { - defaultMessage: 'Metadata JSON editor', + defaultMessage: '_meta field data editor', } ), }, }} /> - ) : ( - // requires children or a field - // For now, we return an empty
if the editor is not visible -
)} diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics_schema.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics_schema.tsx index 0c52037abde459..c5779573394879 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics_schema.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics_schema.tsx @@ -65,7 +65,7 @@ export const logisticsFormSchema: FormSchema = { }, _meta: { label: i18n.translate('xpack.idxMgmt.componentTemplateForm.stepLogistics.metaFieldLabel', { - defaultMessage: 'Metadata (optional)', + defaultMessage: '_meta field data (optional)', }), helpText: ( - Object.keys(json).length ? JSON.stringify(json, null, 2) : '{\n\n}'; - const formSerializer: SerializerFunc = (formData) => { const { dynamicMapping: { @@ -40,22 +37,17 @@ const formSerializer: SerializerFunc = (formData) => { const dynamic = dynamicMappingsEnabled ? true : throwErrorsForUnmappedFields ? 'strict' : false; - let parsedMeta; - try { - parsedMeta = JSON.parse(metaField); - } catch { - parsedMeta = {}; - } - - return { + const serialized = { dynamic, numeric_detection, date_detection, dynamic_date_formats, - _source: { ...sourceField }, - _meta: parsedMeta, + _source: sourceField, + _meta: metaField, _routing, }; + + return serialized; }; const formDeserializer = (formData: GenericObject) => { @@ -64,7 +56,11 @@ const formDeserializer = (formData: GenericObject) => { numeric_detection, date_detection, dynamic_date_formats, - _source: { enabled, includes, excludes }, + _source: { enabled, includes, excludes } = {} as { + enabled?: boolean; + includes?: string[]; + excludes?: string[]; + }, _meta, _routing, } = formData; @@ -82,7 +78,7 @@ const formDeserializer = (formData: GenericObject) => { includes, excludes, }, - metaField: stringifyJson(_meta), + metaField: _meta ?? {}, _routing, }; }; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form_schema.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form_schema.tsx index c06340fd9ae14b..6e80f8b813ec22 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form_schema.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form_schema.tsx @@ -48,10 +48,30 @@ export const configurationFormSchema: FormSchema = { validator: isJsonField( i18n.translate('xpack.idxMgmt.mappingsEditor.configuration.metaFieldEditorJsonError', { defaultMessage: 'The _meta field JSON is not valid.', - }) + }), + { allowEmptyString: true } ), }, ], + deserializer: (value: any) => { + if (value === '') { + return value; + } + return JSON.stringify(value, null, 2); + }, + serializer: (value: string) => { + try { + const parsed = JSON.parse(value); + // If an empty object was passed, strip out this value entirely. + if (!Object.keys(parsed).length) { + return undefined; + } + return parsed; + } catch (error) { + // swallow error and return non-parsed value; + return value; + } + }, }, sourceField: { enabled: { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx index 80937e7da11922..79685d46b6bddb 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx @@ -22,7 +22,7 @@ interface Props { const stringifyJson = (json: { [key: string]: any }) => Array.isArray(json) ? JSON.stringify(json, null, 2) : '[\n\n]'; -const formSerializer: SerializerFunc = (formData) => { +const formSerializer: SerializerFunc = (formData) => { const { dynamicTemplates } = formData; let parsedTemplates; @@ -34,12 +34,14 @@ const formSerializer: SerializerFunc = (formData) => { parsedTemplates = [parsedTemplates]; } } catch { - parsedTemplates = []; + // Silently swallow errors } - return { - dynamic_templates: parsedTemplates, - }; + return Array.isArray(parsedTemplates) && parsedTemplates.length > 0 + ? { + dynamic_templates: parsedTemplates, + } + : undefined; }; const formDeserializer = (formData: { [key: string]: any }) => { @@ -53,7 +55,7 @@ const formDeserializer = (formData: { [key: string]: any }) => { export const TemplatesForm = React.memo(({ value }: Props) => { const isMounted = useRef(undefined); - const { form } = useForm({ + const { form } = useForm({ schema: templatesFormSchema, serializer: formSerializer, deserializer: formDeserializer, diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts index 9fa4a7981c047d..8b3ff600053054 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts @@ -199,7 +199,7 @@ export const getTypeMetaFromSource = ( * * @param fieldsToNormalize The "properties" object from the mappings (or "fields" object for `text` and `keyword` types) */ -export const normalize = (fieldsToNormalize: Fields): NormalizedFields => { +export const normalize = (fieldsToNormalize: Fields = {}): NormalizedFields => { let maxNestedDepth = 0; const normalizeFields = ( diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx index 46dc1176f62b4b..e8fda907377088 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx @@ -39,14 +39,14 @@ export const MappingsEditor = React.memo(({ onChange, value, indexSettings }: Pr } const { - _source = {}, - _meta = {}, + _source, + _meta, _routing, dynamic, numeric_detection, date_detection, dynamic_date_formats, - properties = {}, + properties, dynamic_templates, } = mappingsDefinition; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state.tsx index fb4bfae9740005..ad5056fa73ce16 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state.tsx @@ -19,7 +19,7 @@ import { normalize, deNormalize, stripUndefinedValues } from './lib'; type Mappings = MappingsTemplates & MappingsConfiguration & { - properties: MappingsFields; + properties?: MappingsFields; }; export interface Types { @@ -31,7 +31,7 @@ export interface Types { export interface OnUpdateHandlerArg { isValid?: boolean; - getData: () => Mappings; + getData: () => Mappings | undefined; validate: () => Promise; } @@ -114,13 +114,18 @@ export const MappingsState = React.memo(({ children, onChange, value }: Props) = const configurationData = state.configuration.data.format(); const templatesData = state.templates.data.format(); - return { + const output = { ...stripUndefinedValues({ ...configurationData, ...templatesData, }), - properties: fields, }; + + if (fields && Object.keys(fields).length > 0) { + output.properties = fields; + } + + return Object.keys(output).length > 0 ? (output as Mappings) : undefined; }, validate: async () => { const configurationFormValidator = diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_components.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_components.tsx index 01771f40f89eaf..df0cc791384fe5 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_components.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_components.tsx @@ -25,6 +25,12 @@ interface Props { } const i18nTexts = { + title: ( + + ), description: ( { - onChange({ isValid: true, validate: async () => true, getData: () => components }); + onChange({ + isValid: true, + validate: async () => true, + getData: () => (components.length > 0 ? components : undefined), + }); }, [onChange] ); @@ -63,12 +73,7 @@ export const StepComponents = ({ defaultValue = [], onChange, esDocsBase }: Prop -

- -

+

{i18nTexts.title}

diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx index 44ec4db0873f38..27779411754295 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx @@ -4,7 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ import React, { useEffect } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiButtonEmpty, EuiSpacer } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiButtonEmpty, + EuiSpacer, + EuiLink, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -16,6 +23,7 @@ import { Field, Forms, JsonEditorField, + FormDataProvider, } from '../../../../shared_imports'; import { documentationService } from '../../../services/documentation'; import { schemas, nameConfig, nameConfigWithoutValidations } from '../template_form_schemas'; @@ -24,70 +32,125 @@ import { schemas, nameConfig, nameConfigWithoutValidations } from '../template_f const UseField = getUseField({ component: Field }); const FormRow = getFormRow({ titleTag: 'h3' }); -const fieldsMeta = { - name: { - title: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.nameTitle', { - defaultMessage: 'Name', - }), - description: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.nameDescription', { - defaultMessage: 'A unique identifier for this template.', - }), - testSubject: 'nameField', - }, - indexPatterns: { - title: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.indexPatternsTitle', { - defaultMessage: 'Index patterns', - }), - description: i18n.translate( - 'xpack.idxMgmt.templateForm.stepLogistics.indexPatternsDescription', - { - defaultMessage: 'The index patterns to apply to the template.', - } - ), - testSubject: 'indexPatternsField', - }, - order: { - title: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.orderTitle', { - defaultMessage: 'Merge order', - }), - description: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.orderDescription', { - defaultMessage: 'The merge order when multiple templates match an index.', - }), - testSubject: 'orderField', - }, - priority: { - title: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.priorityTitle', { - defaultMessage: 'Merge priority', - }), - description: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.priorityDescription', { - defaultMessage: 'The merge priority when multiple templates match an index.', - }), - testSubject: 'priorityField', - }, - version: { - title: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.versionTitle', { - defaultMessage: 'Version', - }), - description: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.versionDescription', { - defaultMessage: 'A number that identifies the template to external management systems.', - }), - testSubject: 'versionField', - }, -}; +function getFieldsMeta(esDocsBase: string) { + return { + name: { + title: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.nameTitle', { + defaultMessage: 'Name', + }), + description: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.nameDescription', { + defaultMessage: 'A unique identifier for this template.', + }), + testSubject: 'nameField', + }, + indexPatterns: { + title: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.indexPatternsTitle', { + defaultMessage: 'Index patterns', + }), + description: i18n.translate( + 'xpack.idxMgmt.templateForm.stepLogistics.indexPatternsDescription', + { + defaultMessage: 'The index patterns to apply to the template.', + } + ), + testSubject: 'indexPatternsField', + }, + dataStream: { + title: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.dataStreamTitle', { + defaultMessage: 'Data stream', + }), + description: ( + +
+ + {i18n.translate( + 'xpack.idxMgmt.templateForm.stepLogistics.dataStreamDocumentionLink', + { + defaultMessage: 'Learn more about data streams.', + } + )} + + + ), + }} + /> + ), + testSubject: 'dataStreamField', + }, + order: { + title: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.orderTitle', { + defaultMessage: 'Merge order', + }), + description: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.orderDescription', { + defaultMessage: 'The merge order when multiple templates match an index.', + }), + testSubject: 'orderField', + }, + priority: { + title: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.priorityTitle', { + defaultMessage: 'Priority', + }), + description: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.priorityDescription', { + defaultMessage: 'Only the highest priority template will be applied.', + }), + testSubject: 'priorityField', + }, + version: { + title: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.versionTitle', { + defaultMessage: 'Version', + }), + description: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.versionDescription', { + defaultMessage: 'A number that identifies the template to external management systems.', + }), + testSubject: 'versionField', + }, + }; +} + +interface LogisticsForm { + [key: string]: any; +} + +interface LogisticsFormInternal extends LogisticsForm { + __internal__: { + addMeta: boolean; + }; +} interface Props { - defaultValue: { [key: string]: any }; + defaultValue: LogisticsForm; onChange: (content: Forms.Content) => void; isEditing?: boolean; isLegacy?: boolean; } +function formDeserializer(formData: LogisticsForm): LogisticsFormInternal { + return { + ...formData, + __internal__: { + addMeta: Boolean(formData._meta && Object.keys(formData._meta).length), + }, + }; +} + +function formSerializer(formData: LogisticsFormInternal): LogisticsForm { + const { __internal__, ...rest } = formData; + return rest; +} + export const StepLogistics: React.FunctionComponent = React.memo( ({ defaultValue, isEditing = false, onChange, isLegacy = false }) => { const { form } = useForm({ schema: schemas.logistics, defaultValue, options: { stripEmptyFields: false }, + serializer: formSerializer, + deserializer: formDeserializer, }); /** @@ -117,7 +180,9 @@ export const StepLogistics: React.FunctionComponent = React.memo( return subscription.unsubscribe; }, [onChange]); // eslint-disable-line react-hooks/exhaustive-deps - const { name, indexPatterns, order, priority, version } = fieldsMeta; + const { name, indexPatterns, dataStream, order, priority, version } = getFieldsMeta( + documentationService.getEsDocsBase() + ); return ( <> @@ -180,6 +245,16 @@ export const StepLogistics: React.FunctionComponent = React.memo( /> + {/* Create data stream */} + {isLegacy !== true && ( + + + + )} + {/* Order */} {isLegacy && ( @@ -226,25 +301,35 @@ export const StepLogistics: React.FunctionComponent = React.memo( id="xpack.idxMgmt.templateForm.stepLogistics.metaFieldDescription" defaultMessage="Use the _meta field to store any metadata you want." /> + + } > - + {({ '__internal__.addMeta': addMeta }) => { + return ( + addMeta && ( + + ) + ); }} - /> + )} diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx index 880c7fbd7f23c8..0f4b9de4f6cfa3 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx @@ -168,7 +168,7 @@ export const StepReview: React.FunctionComponent = React.memo( diff --git a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx index 6310ac09488e5d..f5c9be9292cd05 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx @@ -50,7 +50,7 @@ const wizardSections: { [id: string]: { id: WizardSection; label: string } } = { components: { id: 'components', label: i18n.translate('xpack.idxMgmt.templateForm.steps.componentsStepName', { - defaultMessage: 'Components', + defaultMessage: 'Component templates', }), }, settings: { @@ -91,15 +91,9 @@ export const TemplateForm = ({ const indexTemplate = defaultValue ?? { name: '', indexPatterns: [], - composedOf: [], - template: { - settings: {}, - mappings: {}, - aliases: {}, - }, + template: {}, _kbnMeta: { - isManaged: false, - isCloudManaged: false, + type: 'default', hasDatastream: false, isLegacy, }, @@ -150,18 +144,50 @@ export const TemplateForm = ({ ) : null; - const buildTemplateObject = (initialTemplate: TemplateDeserialized) => ( - wizardData: WizardContent - ): TemplateDeserialized => ({ - ...initialTemplate, - ...wizardData.logistics, - composedOf: wizardData.components, - template: { - settings: wizardData.settings, - mappings: wizardData.mappings, - aliases: wizardData.aliases, + /** + * If no mappings, settings or aliases are defined, it is better to not send empty + * object for those values. + * This method takes care of that and other cleanup of empty fields. + * @param template The template object to clean up + */ + const cleanupTemplateObject = (template: TemplateDeserialized) => { + const outputTemplate = { ...template }; + + if (outputTemplate.template.settings === undefined) { + delete outputTemplate.template.settings; + } + if (outputTemplate.template.mappings === undefined) { + delete outputTemplate.template.mappings; + } + if (outputTemplate.template.aliases === undefined) { + delete outputTemplate.template.aliases; + } + if (Object.keys(outputTemplate.template).length === 0) { + delete outputTemplate.template; + } + + return outputTemplate; + }; + + const buildTemplateObject = useCallback( + (initialTemplate: TemplateDeserialized) => ( + wizardData: WizardContent + ): TemplateDeserialized => { + const outputTemplate = { + ...initialTemplate, + ...wizardData.logistics, + composedOf: wizardData.components, + template: { + settings: wizardData.settings, + mappings: wizardData.mappings, + aliases: wizardData.aliases, + }, + }; + + return cleanupTemplateObject(outputTemplate); }, - }); + [] + ); const onSaveTemplate = useCallback( async (wizardData: WizardContent) => { @@ -177,7 +203,7 @@ export const TemplateForm = ({ clearSaveError(); }, - [indexTemplate, onSave, clearSaveError] + [indexTemplate, buildTemplateObject, onSave, clearSaveError] ); return ( diff --git a/x-pack/plugins/index_management/public/application/components/template_form/template_form_schemas.tsx b/x-pack/plugins/index_management/public/application/components/template_form/template_form_schemas.tsx index 5af3b4dd00c4ff..d8c3ad8c259fcf 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/template_form_schemas.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/template_form_schemas.tsx @@ -128,6 +128,32 @@ export const schemas: Record = { }, ], }, + dataStream: { + type: FIELD_TYPES.TOGGLE, + label: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.datastreamLabel', { + defaultMessage: 'Create data stream', + }), + defaultValue: false, + serializer: (value) => { + if (value === true) { + return { + timestamp_field: '@timestamp', + }; + } + }, + deserializer: (value) => { + if (typeof value === 'boolean') { + return value; + } + + /** + * For now, it is enough to have a "data_stream" declared on the index template + * to assume that the template creates a data stream. In the future, this condition + * might change + */ + return value !== undefined; + }, + }, order: { type: FIELD_TYPES.NUMBER, label: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.fieldOrderLabel', { @@ -187,5 +213,13 @@ export const schemas: Record = { } }, }, + __internal__: { + addMeta: { + type: FIELD_TYPES.TOGGLE, + label: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.addMetadataLabel', { + defaultMessage: 'Add metadata', + }), + }, + }, }, }; diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/components/index.ts b/x-pack/plugins/index_management/public/application/sections/home/template_list/components/index.ts index 156d792c26f1db..3954ce04ca0b53 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/components/index.ts +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/components/index.ts @@ -5,3 +5,5 @@ */ export * from './filter_list_button'; + +export * from './template_type_indicator'; diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/components/template_type_indicator.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/components/template_type_indicator.tsx new file mode 100644 index 00000000000000..c6b0e21ebfdc11 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/components/template_type_indicator.tsx @@ -0,0 +1,37 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { EuiBadge } from '@elastic/eui'; + +import { TemplateType } from '../../../../../../common'; + +interface Props { + templateType: TemplateType; +} + +const i18nTexts = { + managed: i18n.translate('xpack.idxMgmt.templateBadgeType.managed', { + defaultMessage: 'Managed', + }), + cloudManaged: i18n.translate('xpack.idxMgmt.templateBadgeType.cloudManaged', { + defaultMessage: 'Cloud-managed', + }), + system: i18n.translate('xpack.idxMgmt.templateBadgeType.system', { defaultMessage: 'System' }), +}; + +export const TemplateTypeIndicator = ({ templateType }: Props) => { + if (templateType === 'default') { + return null; + } + + return ( + + {i18nTexts[templateType]} + + ); +}; diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/legacy_templates/template_table/template_table.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/legacy_templates/template_table/template_table.tsx index b470bcfd7660e4..9203e76fce7873 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/legacy_templates/template_table/template_table.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/legacy_templates/template_table/template_table.tsx @@ -7,7 +7,7 @@ import React, { useState, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiInMemoryTable, EuiIcon, EuiButton, EuiLink, EuiBasicTableColumn } from '@elastic/eui'; +import { EuiInMemoryTable, EuiButton, EuiLink, EuiBasicTableColumn } from '@elastic/eui'; import { ScopedHistory } from 'kibana/public'; import { SendRequestResponse, reactRouterNavigate } from '../../../../../../shared_imports'; import { TemplateListItem } from '../../../../../../../common'; @@ -15,6 +15,8 @@ import { UIM_TEMPLATE_SHOW_DETAILS_CLICK } from '../../../../../../../common/con import { TemplateDeleteModal } from '../../../../../components'; import { encodePathForReactRouter } from '../../../../../services/routing'; import { useServices } from '../../../../../app_context'; +import { TemplateContentIndicator } from '../../../../../components/shared'; +import { TemplateTypeIndicator } from '../../components'; interface Props { templates: TemplateListItem[]; @@ -47,20 +49,23 @@ export const LegacyTemplateTable: React.FunctionComponent = ({ sortable: true, render: (name: TemplateListItem['name'], item: TemplateListItem) => { return ( - /* eslint-disable-next-line @elastic/eui/href-or-on-click */ - uiMetricService.trackMetric('click', UIM_TEMPLATE_SHOW_DETAILS_CLICK) - )} - data-test-subj="templateDetailsLink" - > - {name} - + <> + uiMetricService.trackMetric('click', UIM_TEMPLATE_SHOW_DETAILS_CLICK) + )} + data-test-subj="templateDetailsLink" + > + {name} + +   + + ); }, }, @@ -98,44 +103,30 @@ export const LegacyTemplateTable: React.FunctionComponent = ({ ) : null, }, { - field: 'order', - name: i18n.translate('xpack.idxMgmt.templateList.legacyTable.orderColumnTitle', { - defaultMessage: 'Order', - }), - truncateText: true, - sortable: true, - }, - { - field: 'hasMappings', - name: i18n.translate('xpack.idxMgmt.templateList.legacyTable.mappingsColumnTitle', { - defaultMessage: 'Mappings', + name: i18n.translate('xpack.idxMgmt.templateList.legacyTable.contentColumnTitle', { + defaultMessage: 'Content', }), - truncateText: true, - sortable: true, - render: (hasMappings: boolean) => (hasMappings ? : null), - }, - { - field: 'hasSettings', - name: i18n.translate('xpack.idxMgmt.templateList.legacyTable.settingsColumnTitle', { - defaultMessage: 'Settings', - }), - truncateText: true, - sortable: true, - render: (hasSettings: boolean) => (hasSettings ? : null), - }, - { - field: 'hasAliases', - name: i18n.translate('xpack.idxMgmt.templateList.legacyTable.aliasesColumnTitle', { - defaultMessage: 'Aliases', - }), - truncateText: true, - sortable: true, - render: (hasAliases: boolean) => (hasAliases ? : null), + width: '120px', + render: (item: TemplateListItem) => ( + + {i18n.translate('xpack.idxMgmt.templateList.table.noneDescriptionText', { + defaultMessage: 'None', + })} + + } + /> + ), }, { name: i18n.translate('xpack.idxMgmt.templateList.legacyTable.actionColumnTitle', { defaultMessage: 'Actions', }), + width: '120px', actions: [ { name: i18n.translate('xpack.idxMgmt.templateList.legacyTable.actionEditText', { @@ -153,7 +144,7 @@ export const LegacyTemplateTable: React.FunctionComponent = ({ onClick: ({ name }: TemplateListItem) => { editTemplate(name, true); }, - enabled: ({ _kbnMeta: { isCloudManaged } }: TemplateListItem) => !isCloudManaged, + enabled: ({ _kbnMeta: { type } }: TemplateListItem) => type !== 'cloudManaged', }, { type: 'icon', @@ -188,7 +179,7 @@ export const LegacyTemplateTable: React.FunctionComponent = ({ setTemplatesToDelete([{ name, isLegacy }]); }, isPrimary: true, - enabled: ({ _kbnMeta: { isCloudManaged } }: TemplateListItem) => !isCloudManaged, + enabled: ({ _kbnMeta: { type } }: TemplateListItem) => type !== 'cloudManaged', }, ], }, @@ -208,13 +199,13 @@ export const LegacyTemplateTable: React.FunctionComponent = ({ const selectionConfig = { onSelectionChange: setSelection, - selectable: ({ _kbnMeta: { isCloudManaged } }: TemplateListItem) => !isCloudManaged, + selectable: ({ _kbnMeta: { type } }: TemplateListItem) => type !== 'cloudManaged', selectableMessage: (selectable: boolean) => { if (!selectable) { return i18n.translate( - 'xpack.idxMgmt.templateList.legacyTable.deleteManagedTemplateTooltip', + 'xpack.idxMgmt.templateList.legacyTable.deleteCloudManagedTemplateTooltip', { - defaultMessage: 'You cannot delete a managed template.', + defaultMessage: 'You cannot delete a cloud-managed template.', } ); } diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_summary.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_summary.tsx index de2fc29ec85435..0c403e69d2e765 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_summary.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_summary.tsx @@ -17,6 +17,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiCodeBlock, + EuiSpacer, } from '@elastic/eui'; import { useAppContext } from '../../../../../app_context'; import { TemplateDeserialized } from '../../../../../../../common'; @@ -57,163 +58,169 @@ export const TabSummary: React.FunctionComponent = ({ templateDetails }) } = useAppContext(); return ( - - - - {/* Index patterns */} - - - - - {numIndexPatterns > 1 ? ( - -
    - {indexPatterns.map((indexName: string, i: number) => { - return ( -
  • - - {indexName} - -
  • - ); - })} -
-
+ <> + + + + {/* Index patterns */} + + + + + {numIndexPatterns > 1 ? ( + +
    + {indexPatterns.map((indexName: string, i: number) => { + return ( +
  • + + {indexName} + +
  • + ); + })} +
+
+ ) : ( + indexPatterns.toString() + )} +
+ + {/* Priority / Order */} + {isLegacy !== true ? ( + <> + + + + + {priority || priority === 0 ? priority : i18nTexts.none} + + ) : ( - indexPatterns.toString() + <> + + + + + {order || order === 0 ? order : i18nTexts.none} + + )} -
- - {/* Priority / Order */} - {isLegacy !== true ? ( - <> - - - - - {priority || priority === 0 ? priority : i18nTexts.none} - - - ) : ( - <> - - - - - {order || order === 0 ? order : i18nTexts.none} - - - )} - {/* Components */} - {isLegacy !== true && ( - <> - - - - - {composedOf && composedOf.length > 0 ? ( -
    - {composedOf.map((component) => ( -
  • - - {component} - -
  • - ))} -
- ) : ( - i18nTexts.none - )} -
- - )} -
-
+ {/* Components */} + {isLegacy !== true && ( + <> + + + + + {composedOf && composedOf.length > 0 ? ( +
    + {composedOf.map((component) => ( +
  • + + {component} + +
  • + ))} +
+ ) : ( + i18nTexts.none + )} +
+ + )} + +
- - - {/* ILM Policy (only for legacy as composable template could have ILM policy + + + {/* ILM Policy (only for legacy as composable template could have ILM policy inside one of their components) */} - {isLegacy && ( - <> - - - - - {ilmPolicy && ilmPolicy.name ? ( - - {ilmPolicy.name} - - ) : ( - i18nTexts.none - )} - - - )} + {isLegacy && ( + <> + + + + + {ilmPolicy && ilmPolicy.name ? ( + + {ilmPolicy.name} + + ) : ( + i18nTexts.none + )} + + + )} + + {/* Has data stream? (only for composable template) */} + {isLegacy !== true && ( + <> + + + + + {hasDatastream ? i18nTexts.yes : i18nTexts.no} + + + )} - {/* Has data stream? (only for composable template) */} - {isLegacy !== true && ( - <> - - - - - {hasDatastream ? i18nTexts.yes : i18nTexts.no} - - - )} + {/* Version */} + + + + + {version || version === 0 ? version : i18nTexts.none} + + + +
- {/* Version */} - - - - - {version || version === 0 ? version : i18nTexts.none} - + - {/* Metadata (optional) */} - {isLegacy !== true && _meta && ( - <> - - - - - {JSON.stringify(_meta, null, 2)} - - - )} - - - + + {/* Metadata (optional) */} + {isLegacy !== true && _meta && ( + <> + + + + + {JSON.stringify(_meta, null, 2)} + + + )} + + ); }; diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details_content.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details_content.tsx index 34e90aef51701a..5b726013a1d922 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details_content.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details_content.tsx @@ -36,6 +36,7 @@ import { useLoadIndexTemplate } from '../../../../services/api'; import { decodePathFromReactRouter } from '../../../../services/routing'; import { useServices } from '../../../../app_context'; import { TabAliases, TabMappings, TabSettings } from '../../../../components/shared'; +import { TemplateTypeIndicator } from '../components'; import { TabSummary } from './tabs'; const SUMMARY_TAB_ID = 'summary'; @@ -98,7 +99,7 @@ export const TemplateDetailsContent = ({ decodedTemplateName, isLegacy ); - const isCloudManaged = templateDetails?._kbnMeta.isCloudManaged ?? false; + const isCloudManaged = templateDetails?._kbnMeta.type === 'cloudManaged'; const [templateToDelete, setTemplateToDelete] = useState< Array<{ name: string; isLegacy?: boolean }> >([]); @@ -111,6 +112,12 @@ export const TemplateDetailsContent = ({

{decodedTemplateName} + {templateDetails && ( + <> +   + + + )}

@@ -163,16 +170,16 @@ export const TemplateDetailsContent = ({ } color="primary" size="s" > diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_list.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_list.tsx index 18a65407ee20db..f421bc5d87a54e 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_list.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_list.tsx @@ -37,13 +37,19 @@ import { TemplateDetails } from './template_details'; import { LegacyTemplateTable } from './legacy_templates/template_table'; import { FilterListButton, Filters } from './components'; -type FilterName = 'composable' | 'system'; +type FilterName = 'managed' | 'cloudManaged' | 'system'; interface MatchParams { templateName?: string; } -const stripOutSystemTemplates = (templates: TemplateListItem[]): TemplateListItem[] => - templates.filter((template) => !template.name.startsWith('.')); +function filterTemplates(templates: TemplateListItem[], types: string[]): TemplateListItem[] { + return templates.filter((template) => { + if (template._kbnMeta.type === 'default') { + return true; + } + return types.includes(template._kbnMeta.type); + }); +} export const TemplateList: React.FunctionComponent> = ({ match: { @@ -56,12 +62,18 @@ export const TemplateList: React.FunctionComponent>({ - composable: { - name: i18n.translate('xpack.idxMgmt.indexTemplatesList.viewComposableTemplateLabel', { - defaultMessage: 'Composable templates', + managed: { + name: i18n.translate('xpack.idxMgmt.indexTemplatesList.viewManagedTemplateLabel', { + defaultMessage: 'Managed templates', }), checked: 'on', }, + cloudManaged: { + name: i18n.translate('xpack.idxMgmt.indexTemplatesList.viewCloudManagedTemplateLabel', { + defaultMessage: 'Cloud-managed templates', + }), + checked: 'off', + }, system: { name: i18n.translate('xpack.idxMgmt.indexTemplatesList.viewSystemTemplateLabel', { defaultMessage: 'System templates', @@ -72,18 +84,19 @@ export const TemplateList: React.FunctionComponent { if (!allTemplates) { + // If templates are not fetched, return empty arrays. return { templates: [], legacyTemplates: [] }; } - return filters.system.checked === 'on' - ? allTemplates - : { - templates: stripOutSystemTemplates(allTemplates.templates), - legacyTemplates: stripOutSystemTemplates(allTemplates.legacyTemplates), - }; - }, [allTemplates, filters.system.checked]); + const visibleTemplateTypes = Object.entries(filters) + .filter(([name, _filter]) => _filter.checked === 'on') + .map(([name]) => name); - const showComposableTemplateTable = filters.composable.checked === 'on'; + return { + templates: filterTemplates(allTemplates.templates, visibleTemplateTypes), + legacyTemplates: filterTemplates(allTemplates.legacyTemplates, visibleTemplateTypes), + }; + }, [allTemplates, filters]); const selectedTemplate = Boolean(templateName) ? { @@ -154,8 +167,8 @@ export const TemplateList: React.FunctionComponent ); - const renderTemplatesTable = () => - showComposableTemplateTable ? ( + const renderTemplatesTable = () => { + return ( <> - ) : null; + ); + }; const renderLegacyTemplatesTable = () => ( <> diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_table/template_table.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_table/template_table.tsx index 55a777363d06fe..3dffdcde160f16 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_table/template_table.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_table/template_table.tsx @@ -7,14 +7,7 @@ import React, { useState, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiInMemoryTable, - EuiBasicTableColumn, - EuiButton, - EuiLink, - EuiBadge, - EuiIcon, -} from '@elastic/eui'; +import { EuiInMemoryTable, EuiBasicTableColumn, EuiButton, EuiLink, EuiIcon } from '@elastic/eui'; import { ScopedHistory } from 'kibana/public'; import { TemplateListItem } from '../../../../../../common'; @@ -24,6 +17,7 @@ import { encodePathForReactRouter } from '../../../../services/routing'; import { useServices } from '../../../../app_context'; import { TemplateDeleteModal } from '../../../../components'; import { TemplateContentIndicator } from '../../../../components/shared'; +import { TemplateTypeIndicator } from '../components'; interface Props { templates: TemplateListItem[]; @@ -70,13 +64,7 @@ export const TemplateTable: React.FunctionComponent = ({ {name}   - {item._kbnMeta.isManaged ? ( - - Managed - - ) : ( - '' - )} + ); }, @@ -99,14 +87,6 @@ export const TemplateTable: React.FunctionComponent = ({ sortable: true, render: (composedOf: string[] = []) => {composedOf.join(', ')}, }, - { - field: 'priority', - name: i18n.translate('xpack.idxMgmt.templateList.table.priorityColumnTitle', { - defaultMessage: 'Priority', - }), - truncateText: true, - sortable: true, - }, { name: i18n.translate('xpack.idxMgmt.templateList.table.dataStreamColumnTitle', { defaultMessage: 'Data stream', @@ -119,7 +99,7 @@ export const TemplateTable: React.FunctionComponent = ({ name: i18n.translate('xpack.idxMgmt.templateList.table.contentColumnTitle', { defaultMessage: 'Content', }), - truncateText: true, + width: '120px', render: (item: TemplateListItem) => ( = ({ name: i18n.translate('xpack.idxMgmt.templateList.table.actionColumnTitle', { defaultMessage: 'Actions', }), + width: '120px', actions: [ { name: i18n.translate('xpack.idxMgmt.templateList.table.actionEditText', { @@ -153,7 +134,7 @@ export const TemplateTable: React.FunctionComponent = ({ onClick: ({ name }: TemplateListItem) => { editTemplate(name); }, - enabled: ({ _kbnMeta: { isCloudManaged } }: TemplateListItem) => !isCloudManaged, + enabled: ({ _kbnMeta: { type } }: TemplateListItem) => type !== 'cloudManaged', }, { type: 'icon', @@ -182,7 +163,7 @@ export const TemplateTable: React.FunctionComponent = ({ setTemplatesToDelete([{ name, isLegacy }]); }, isPrimary: true, - enabled: ({ _kbnMeta: { isCloudManaged } }: TemplateListItem) => !isCloudManaged, + enabled: ({ _kbnMeta: { type } }: TemplateListItem) => type !== 'cloudManaged', }, ], }, @@ -202,13 +183,13 @@ export const TemplateTable: React.FunctionComponent = ({ const selectionConfig = { onSelectionChange: setSelection, - selectable: ({ _kbnMeta: { isCloudManaged } }: TemplateListItem) => !isCloudManaged, + selectable: ({ _kbnMeta: { type } }: TemplateListItem) => type !== 'cloudManaged', selectableMessage: (selectable: boolean) => { if (!selectable) { return i18n.translate( - 'xpack.idxMgmt.templateList.legacyTable.deleteManagedTemplateTooltip', + 'xpack.idxMgmt.templateList.table.deleteCloudManagedTemplateTooltip', { - defaultMessage: 'You cannot delete a managed template.', + defaultMessage: 'You cannot delete a cloud-managed template.', } ); } diff --git a/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx b/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx index 6ecefe18b1a61e..29fd2e02120fc1 100644 --- a/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx +++ b/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx @@ -85,11 +85,11 @@ export const TemplateEdit: React.FunctionComponent): boolean => { + return obj === undefined || Object.keys(obj).length === 0 ? false : true; +}; export const getTemplate = ({ name = getRandomString(), @@ -13,31 +17,35 @@ export const getTemplate = ({ order = getRandomNumber(), indexPatterns = [], template: { settings, aliases, mappings } = {}, - isManaged = false, - isCloudManaged = false, hasDatastream = false, isLegacy = false, + type = 'default', }: Partial< TemplateDeserialized & { isLegacy?: boolean; - isManaged: boolean; - isCloudManaged: boolean; + type?: TemplateType; hasDatastream: boolean; } -> = {}): TemplateDeserialized => ({ - name, - version, - order, - indexPatterns, - template: { - aliases, - mappings, - settings, - }, - _kbnMeta: { - isManaged, - isCloudManaged, - hasDatastream, - isLegacy, - }, -}); +> = {}): TemplateDeserialized & TemplateListItem => { + const indexTemplate = { + name, + version, + order, + indexPatterns, + template: { + aliases, + mappings, + settings, + }, + hasSettings: objHasProperties(settings), + hasMappings: objHasProperties(mappings), + hasAliases: objHasProperties(aliases), + _kbnMeta: { + type, + hasDatastream, + isLegacy, + }, + }; + + return indexTemplate; +}; diff --git a/x-pack/test/api_integration/apis/management/index_management/templates.helpers.js b/x-pack/test/api_integration/apis/management/index_management/templates.helpers.js index a563b956df3445..d24a856399f10b 100644 --- a/x-pack/test/api_integration/apis/management/index_management/templates.helpers.js +++ b/x-pack/test/api_integration/apis/management/index_management/templates.helpers.js @@ -14,7 +14,12 @@ export const registerHelpers = ({ supertest }) => { const getOneTemplate = (name, isLegacy = false) => supertest.get(`${API_BASE_PATH}/index_templates/${name}?legacy=${isLegacy}`); - const getTemplatePayload = (name, indexPatterns = INDEX_PATTERNS, isLegacy = false) => { + const getTemplatePayload = ( + name, + indexPatterns = INDEX_PATTERNS, + isLegacy = false, + type = 'default' + ) => { const baseTemplate = { name, indexPatterns, @@ -48,6 +53,7 @@ export const registerHelpers = ({ supertest }) => { }, _kbnMeta: { isLegacy, + type, }, }; From 09da11047df7348e05a8eb75da82bd3fd27a294c Mon Sep 17 00:00:00 2001 From: Kevin Logan <56395104+kevinlog@users.noreply.github.com> Date: Thu, 9 Jul 2020 15:33:44 -0400 Subject: [PATCH 17/49] [SECURITY_SOLUTION] adjust policy onboarding view, check for Ingest permissions (#70536) * adjust policy onboarding view * correct test subj * fix tests * re-enable tests * add no permissions view * adjust onbording look * adjust text * use ingest hook, add tests * adjust text * address comments * beta badges * fix test * correct timeline flyout Co-authored-by: Elastic Machine --- .../utils/timeline/use_show_timeline.tsx | 2 +- .../components/management_empty_state.tsx | 324 ++++++++++-------- .../pages/endpoint_hosts/view/index.tsx | 28 +- .../public/management/pages/index.test.tsx | 36 ++ .../public/management/pages/index.tsx | 42 +++ .../pages/policy/view/policy_list.test.tsx | 4 +- .../pages/policy/view/policy_list.tsx | 26 +- .../public/overview/pages/overview.test.tsx | 25 ++ .../public/overview/pages/overview.tsx | 4 +- 9 files changed, 327 insertions(+), 164 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/pages/index.test.tsx diff --git a/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.tsx b/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.tsx index a9c6660ba9c68b..14c38c5d6dab69 100644 --- a/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.tsx +++ b/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.tsx @@ -7,7 +7,7 @@ import { useState, useEffect } from 'react'; import { useRouteSpy } from '../route/use_route_spy'; -const hideTimelineForRoutes = [`/cases/configure`, '/management']; +const hideTimelineForRoutes = [`/cases/configure`, '/administration']; export const useShowTimeline = () => { const [{ pageName, pathName }] = useRouteSpy(); diff --git a/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx b/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx index c3d6cb48e4dae7..6486b1f3be6d12 100644 --- a/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx +++ b/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx @@ -16,6 +16,7 @@ import { EuiSelectable, EuiSelectableMessage, EuiSelectableProps, + EuiIcon, EuiLoadingSpinner, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -35,71 +36,121 @@ const PolicyEmptyState = React.memo<{ onActionClick: (event: MouseEvent) => void; actionDisabled?: boolean; }>(({ loading, onActionClick, actionDisabled }) => { - const policySteps = useMemo( - () => [ - { - title: i18n.translate('xpack.securitySolution.endpoint.policyList.stepOneTitle', { - defaultMessage: 'Head over to Ingest Manager.', - }), - children: ( - - - - ), - }, - { - title: i18n.translate('xpack.securitySolution.endpoint.policyList.stepTwoTitle', { - defaultMessage: 'We’ll create a recommended security policy for you.', - }), - children: ( - - - - ), - }, - { - title: i18n.translate('xpack.securitySolution.endpoint.policyList.stepThreeTitle', { - defaultMessage: 'Enroll your agents through Fleet.', - }), - children: ( - - - - ), - }, - ], - [] - ); - return ( - - } - bodyComponent={ - - } - /> +
+ {loading ? ( + + + + + + ) : ( + + + +

+ +

+
+ + + + + + + + + + + + + + + + + +

+ +

+
+
+
+ + + + +
+ + + + + + + +

+ +

+
+
+
+ + + + +
+
+ + + + + + + + + + + + +
+ + + +
+ )} +
); }); @@ -114,17 +165,17 @@ const HostsEmptyState = React.memo<{ () => [ { title: i18n.translate('xpack.securitySolution.endpoint.hostList.stepOneTitle', { - defaultMessage: 'Select a policy you created from the list below.', + defaultMessage: 'Select the policy you want to use to protect your hosts', }), children: ( <> - + - + - - + + + + + + + + + + + + ), }, ], - [selectionOptions, handleSelectableOnChange, loading] + [selectionOptions, handleSelectableOnChange, loading, actionDisabled, onActionClick] ); return ( } bodyComponent={ } /> @@ -198,80 +265,45 @@ const HostsEmptyState = React.memo<{ const ManagementEmptyState = React.memo<{ loading: boolean; - onActionClick?: (event: MouseEvent) => void; - actionDisabled?: boolean; - actionButton?: JSX.Element; dataTestSubj: string; steps?: ManagementStep[]; headerComponent: JSX.Element; bodyComponent: JSX.Element; -}>( - ({ - loading, - onActionClick, - actionDisabled, - dataTestSubj, - steps, - actionButton, - headerComponent, - bodyComponent, - }) => { - return ( -
- {loading ? ( - - - - - - ) : ( - <> - - -

{headerComponent}

-
- - - {bodyComponent} - - - {steps && ( - - - - - - )} +}>(({ loading, dataTestSubj, steps, headerComponent, bodyComponent }) => { + return ( +
+ {loading ? ( + + + + + + ) : ( + <> + + +

{headerComponent}

+
+ + + {bodyComponent} + + + {steps && ( - <> - {actionButton ? ( - actionButton - ) : ( - - - - )} - + - - )} -
- ); - } -); + )} + + )} +
+ ); +}); PolicyEmptyState.displayName = 'PolicyEmptyState'; HostsEmptyState.displayName = 'HostsEmptyState'; ManagementEmptyState.displayName = 'ManagementEmptyState'; -export { PolicyEmptyState, HostsEmptyState, ManagementEmptyState }; +export { PolicyEmptyState, HostsEmptyState }; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index 492c75607a2555..8edeab15d6a091 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -16,6 +16,9 @@ import { EuiHealth, EuiToolTip, EuiSelectableProps, + EuiBetaBadge, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import { useHistory } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; @@ -374,14 +377,25 @@ export const HostList = () => { data-test-subj="hostPage" headerLeft={ <> - -

- + + +

+ +

+
+
+ + -

-
+ +

diff --git a/x-pack/plugins/security_solution/public/management/pages/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/index.test.tsx new file mode 100644 index 00000000000000..5ec42671ec3d21 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/index.test.tsx @@ -0,0 +1,36 @@ +/* + * 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 { ManagementContainer } from './index'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../common/mock/endpoint'; +import { useIngestEnabledCheck } from '../../common/hooks/endpoint/ingest_enabled'; + +jest.mock('../../common/hooks/endpoint/ingest_enabled'); + +describe('when in the Admistration tab', () => { + let render: () => ReturnType; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + render = () => mockedContext.render(); + }); + + it('should display the No Permissions view when Ingest is OFF', async () => { + (useIngestEnabledCheck as jest.Mock).mockReturnValue({ allEnabled: false }); + const renderResult = render(); + const noIngestPermissions = await renderResult.findByTestId('noIngestPermissions'); + expect(noIngestPermissions).not.toBeNull(); + }); + + it('should display the Management view when Ingest is ON', async () => { + (useIngestEnabledCheck as jest.Mock).mockReturnValue({ allEnabled: true }); + const renderResult = render(); + const hostPage = await renderResult.findByTestId('hostPage'); + expect(hostPage).not.toBeNull(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/index.tsx b/x-pack/plugins/security_solution/public/management/pages/index.tsx index 2cf07b9b4382eb..30800234ab24c3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/index.tsx @@ -7,6 +7,8 @@ import React, { memo } from 'react'; import { useHistory, Route, Switch } from 'react-router-dom'; +import { EuiText, EuiEmptyPrompt } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { PolicyContainer } from './policy'; import { MANAGEMENT_ROUTING_HOSTS_PATH, @@ -16,9 +18,49 @@ import { import { NotFoundPage } from '../../app/404'; import { HostsContainer } from './endpoint_hosts'; import { getHostListPath } from '../common/routing'; +import { SpyRoute } from '../../common/utils/route/spy_routes'; +import { SecurityPageName } from '../../app/types'; +import { useIngestEnabledCheck } from '../../common/hooks/endpoint/ingest_enabled'; + +const NoPermissions = memo(() => { + return ( + <> + + } + body={ +

+ + + +

+ } + /> + + + ); +}); +NoPermissions.displayName = 'NoPermissions'; export const ManagementContainer = memo(() => { const history = useHistory(); + const { allEnabled: isIngestEnabled } = useIngestEnabledCheck(); + + if (!isIngestEnabled) { + return ; + } + return ( diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx index db622ceb87b631..047aa6918736e0 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx @@ -37,9 +37,9 @@ describe('when on the policies page', () => { expect(table).not.toBeNull(); }); - it('should display the onboarding steps', async () => { + it('should display the instructions', async () => { const renderResult = render(); - const onboardingSteps = await renderResult.findByTestId('onboardingSteps'); + const onboardingSteps = await renderResult.findByTestId('policyOnboardingInstructions'); expect(onboardingSteps).not.toBeNull(); }); 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 fc120d9782e674..8a77264c354ad4 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, + EuiBetaBadge, EuiHorizontalRule, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -395,14 +396,25 @@ export const PolicyList = React.memo(() => { data-test-subj="policyListPage" headerLeft={ <> - -

- + + +

+ +

+
+
+ + -

-
+ +

diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx index 6f13f64ca1bffa..43d8fb10508b7c 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx @@ -104,6 +104,7 @@ describe('Overview', () => { const mockuseMessagesStorage: jest.Mock = useMessagesStorage as jest.Mock; mockuseMessagesStorage.mockImplementation(() => endpointNoticeMessage(false)); + (useIngestEnabledCheck as jest.Mock).mockReturnValue({ allEnabled: true }); const wrapper = mount( @@ -128,6 +129,7 @@ describe('Overview', () => { const mockuseMessagesStorage: jest.Mock = useMessagesStorage as jest.Mock; mockuseMessagesStorage.mockImplementation(() => endpointNoticeMessage(false)); + (useIngestEnabledCheck as jest.Mock).mockReturnValue({ allEnabled: true }); const wrapper = mount( @@ -152,6 +154,7 @@ describe('Overview', () => { const mockuseMessagesStorage: jest.Mock = useMessagesStorage as jest.Mock; mockuseMessagesStorage.mockImplementation(() => endpointNoticeMessage(true)); + (useIngestEnabledCheck as jest.Mock).mockReturnValue({ allEnabled: true }); const wrapper = mount( @@ -171,6 +174,7 @@ describe('Overview', () => { const mockuseMessagesStorage: jest.Mock = useMessagesStorage as jest.Mock; mockuseMessagesStorage.mockImplementation(() => endpointNoticeMessage(true)); + (useIngestEnabledCheck as jest.Mock).mockReturnValue({ allEnabled: true }); const wrapper = mount( @@ -190,6 +194,27 @@ describe('Overview', () => { const mockuseMessagesStorage: jest.Mock = useMessagesStorage as jest.Mock; mockuseMessagesStorage.mockImplementation(() => endpointNoticeMessage(false)); + (useIngestEnabledCheck as jest.Mock).mockReturnValue({ allEnabled: true }); + + const wrapper = mount( + + + + + + ); + expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(false); + }); + + test('it does NOT render the Endpoint banner when Ingest is NOT available', () => { + (useWithSource as jest.Mock).mockReturnValue({ + indicesExist: true, + indexPattern: {}, + }); + + const mockuseMessagesStorage: jest.Mock = useMessagesStorage as jest.Mock; + mockuseMessagesStorage.mockImplementation(() => endpointNoticeMessage(true)); + (useIngestEnabledCheck as jest.Mock).mockReturnValue({ allEnabled: false }); const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx index 2a522d3ea8fde1..6563f3c2b824da 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx @@ -29,6 +29,7 @@ import { SecurityPageName } from '../../app/types'; import { EndpointNotice } from '../components/endpoint_notice'; import { useMessagesStorage } from '../../common/containers/local_storage/use_messages_storage'; import { ENDPOINT_METADATA_INDEX } from '../../../common/constants'; +import { useIngestEnabledCheck } from '../../common/hooks/endpoint/ingest_enabled'; const DEFAULT_QUERY: Query = { query: '', language: 'kuery' }; const NO_FILTERS: Filter[] = []; @@ -64,6 +65,7 @@ const OverviewComponent: React.FC = ({ setDismissMessage(true); addMessage('management', 'dismissEndpointNotice'); }, [addMessage]); + const { allEnabled: isIngestEnabled } = useIngestEnabledCheck(); return ( <> @@ -74,7 +76,7 @@ const OverviewComponent: React.FC = ({ - {!dismissMessage && !metadataIndexExists && ( + {!dismissMessage && !metadataIndexExists && isIngestEnabled && ( <> From 9037018ed828afce831f1fc21c30df2a727d6841 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Thu, 9 Jul 2020 12:54:32 -0700 Subject: [PATCH 18/49] [Ingest Manager] Integrate beta messaging with Add Data (#71147) * Add methods to register directory notices and header links in tutorials, and use registered components when rendering tutorial directory * Add methods to register module notices components in tutorial pages, and use registered components when rendering tutorial page * Add `moduleName` field to server tutorial schema and test fixure * Surface `moduleName` field from built in tutorials and registered apm tutorial * Export component types * Add KibanaContextProvider to home plugin app render * Move setHttpClient to ingest manager plugin setup() method; add home as optional plugin dep; register tutorial module notice * Fix key prop warnings * Add dismissable tutorial directory notice and corresponding ingest manager global setting field * Add tutorial directory header link and tie it to the state of the dismissible directory notice via observable * Put spacing inside module notice component itself * Check if ingest manager is available in current space Co-authored-by: Elastic Machine --- src/plugins/apm_oss/server/tutorial/index.ts | 2 + .../home/public/application/application.tsx | 16 +- .../components/tutorial/tutorial.js | 18 ++ .../components/tutorial/tutorial.test.js | 3 + .../components/tutorial_directory.js | 60 ++++++- src/plugins/home/public/index.ts | 3 + src/plugins/home/public/plugin.ts | 2 +- .../home/public/services/tutorials/index.ts | 9 +- .../tutorials/tutorial_service.mock.ts | 6 + .../tutorials/tutorial_service.test.ts | 55 ------- .../tutorials/tutorial_service.test.tsx | 151 +++++++++++++++++ .../services/tutorials/tutorial_service.ts | 62 +++++++ .../services/tutorials/lib/tutorial_schema.ts | 1 + .../tutorials/lib/tutorials_registry_types.ts | 1 + .../tutorials/tutorials_registry.test.ts | 1 + .../server/tutorials/activemq_logs/index.ts | 1 + .../tutorials/activemq_metrics/index.ts | 1 + .../tutorials/aerospike_metrics/index.ts | 1 + .../server/tutorials/apache_logs/index.ts | 1 + .../server/tutorials/apache_metrics/index.ts | 1 + .../home/server/tutorials/auditbeat/index.ts | 2 + .../home/server/tutorials/aws_logs/index.ts | 1 + .../server/tutorials/aws_metrics/index.ts | 1 + .../home/server/tutorials/azure_logs/index.ts | 1 + .../server/tutorials/azure_metrics/index.ts | 1 + .../server/tutorials/ceph_metrics/index.ts | 1 + .../home/server/tutorials/cisco_logs/index.ts | 1 + .../server/tutorials/cloudwatch_logs/index.ts | 2 + .../tutorials/cockroachdb_metrics/index.ts | 1 + .../server/tutorials/consul_metrics/index.ts | 1 + .../server/tutorials/coredns_logs/index.ts | 1 + .../server/tutorials/coredns_metrics/index.ts | 1 + .../tutorials/couchbase_metrics/index.ts | 1 + .../server/tutorials/couchdb_metrics/index.ts | 1 + .../server/tutorials/docker_metrics/index.ts | 1 + .../tutorials/dropwizard_metrics/index.ts | 1 + .../tutorials/elasticsearch_logs/index.ts | 1 + .../tutorials/elasticsearch_metrics/index.ts | 1 + .../server/tutorials/envoyproxy_logs/index.ts | 1 + .../tutorials/envoyproxy_metrics/index.ts | 1 + .../server/tutorials/etcd_metrics/index.ts | 1 + .../server/tutorials/golang_metrics/index.ts | 1 + .../tutorials/googlecloud_metrics/index.ts | 1 + .../server/tutorials/haproxy_metrics/index.ts | 1 + .../home/server/tutorials/ibmmq_logs/index.ts | 1 + .../server/tutorials/ibmmq_metrics/index.ts | 1 + .../home/server/tutorials/iis_logs/index.ts | 1 + .../server/tutorials/iis_metrics/index.ts | 1 + .../server/tutorials/iptables_logs/index.ts | 1 + .../home/server/tutorials/kafka_logs/index.ts | 1 + .../server/tutorials/kafka_metrics/index.ts | 1 + .../server/tutorials/kibana_metrics/index.ts | 1 + .../tutorials/kubernetes_metrics/index.ts | 1 + .../server/tutorials/logstash_logs/index.ts | 1 + .../tutorials/logstash_metrics/index.ts | 1 + .../tutorials/memcached_metrics/index.ts | 1 + .../server/tutorials/mongodb_metrics/index.ts | 1 + .../server/tutorials/mssql_metrics/index.ts | 1 + .../server/tutorials/munin_metrics/index.ts | 1 + .../home/server/tutorials/mysql_logs/index.ts | 1 + .../server/tutorials/mysql_metrics/index.ts | 1 + .../home/server/tutorials/nats_logs/index.ts | 1 + .../server/tutorials/nats_metrics/index.ts | 1 + .../home/server/tutorials/netflow/index.ts | 2 + .../home/server/tutorials/nginx_logs/index.ts | 1 + .../server/tutorials/nginx_metrics/index.ts | 1 + .../tutorials/openmetrics_metrics/index.ts | 1 + .../server/tutorials/oracle_metrics/index.ts | 1 + .../server/tutorials/osquery_logs/index.ts | 1 + .../server/tutorials/php_fpm_metrics/index.ts | 1 + .../server/tutorials/postgresql_logs/index.ts | 1 + .../tutorials/postgresql_metrics/index.ts | 1 + .../tutorials/prometheus_metrics/index.ts | 1 + .../tutorials/rabbitmq_metrics/index.ts | 1 + .../home/server/tutorials/redis_logs/index.ts | 1 + .../server/tutorials/redis_metrics/index.ts | 1 + .../redisenterprise_metrics/index.ts | 1 + .../server/tutorials/stan_metrics/index.ts | 1 + .../server/tutorials/statsd_metrics/index.ts | 1 + .../server/tutorials/suricata_logs/index.ts | 1 + .../server/tutorials/system_logs/index.ts | 1 + .../server/tutorials/system_metrics/index.ts | 1 + .../server/tutorials/traefik_logs/index.ts | 1 + .../server/tutorials/traefik_metrics/index.ts | 1 + .../server/tutorials/uptime_monitors/index.ts | 2 + .../server/tutorials/uwsgi_metrics/index.ts | 1 + .../server/tutorials/vsphere_metrics/index.ts | 1 + .../tutorials/windows_event_logs/index.ts | 2 + .../server/tutorials/windows_metrics/index.ts | 1 + .../home/server/tutorials/zeek_logs/index.ts | 1 + .../tutorials/zookeeper_metrics/index.ts | 1 + .../common/types/models/settings.ts | 1 + x-pack/plugins/ingest_manager/kibana.json | 2 +- .../components/home_integration/index.ts | 7 + .../tutorial_directory_notice.tsx | 154 ++++++++++++++++++ .../tutorial_module_notice.tsx | 74 +++++++++ .../applications/ingest_manager/index.tsx | 3 +- .../plugins/ingest_manager/public/plugin.ts | 19 +++ .../server/saved_objects/index.ts | 1 + .../server/types/rest_spec/settings.ts | 1 + 100 files changed, 663 insertions(+), 70 deletions(-) delete mode 100644 src/plugins/home/public/services/tutorials/tutorial_service.test.ts create mode 100644 src/plugins/home/public/services/tutorials/tutorial_service.test.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/home_integration/index.ts create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/home_integration/tutorial_directory_notice.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/home_integration/tutorial_module_notice.tsx diff --git a/src/plugins/apm_oss/server/tutorial/index.ts b/src/plugins/apm_oss/server/tutorial/index.ts index aa775d007de309..42609f7d759174 100644 --- a/src/plugins/apm_oss/server/tutorial/index.ts +++ b/src/plugins/apm_oss/server/tutorial/index.ts @@ -26,6 +26,7 @@ import { APM_STATIC_INDEX_PATTERN_ID } from '../../common/index_pattern_constant const apmIntro = i18n.translate('apmOss.tutorial.introduction', { defaultMessage: 'Collect in-depth performance metrics and errors from inside your applications.', }); +const moduleName = 'apm'; export const tutorialProvider = ({ indexPatternTitle, @@ -68,6 +69,7 @@ export const tutorialProvider = ({ name: i18n.translate('apmOss.tutorial.specProvider.name', { defaultMessage: 'APM', }), + moduleName, category: TutorialsCategory.OTHER, shortDescription: apmIntro, longDescription: i18n.translate('apmOss.tutorial.specProvider.longDescription', { diff --git a/src/plugins/home/public/application/application.tsx b/src/plugins/home/public/application/application.tsx index 3729e4e2aa089d..627bd10d7c2c83 100644 --- a/src/plugins/home/public/application/application.tsx +++ b/src/plugins/home/public/application/application.tsx @@ -20,14 +20,19 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { i18n } from '@kbn/i18n'; -import { ScopedHistory } from 'kibana/public'; +import { ScopedHistory, CoreStart } from 'kibana/public'; +import { KibanaContextProvider } from '../../../kibana_react/public'; // @ts-ignore import { HomeApp } from './components/home_app'; import { getServices } from './kibana_services'; import './index.scss'; -export const renderApp = async (element: HTMLElement, history: ScopedHistory) => { +export const renderApp = async ( + element: HTMLElement, + coreStart: CoreStart, + history: ScopedHistory +) => { const homeTitle = i18n.translate('home.breadcrumbs.homeTitle', { defaultMessage: 'Home' }); const { featureCatalogue, chrome } = getServices(); @@ -36,7 +41,12 @@ export const renderApp = async (element: HTMLElement, history: ScopedHistory) => chrome.setBreadcrumbs([{ text: homeTitle }]); - render(, element); + render( + + + , + element + ); // dispatch synthetic hash change event to update hash history objects // this is necessary because hash updates triggered by using popState won't trigger this event naturally. diff --git a/src/plugins/home/public/application/components/tutorial/tutorial.js b/src/plugins/home/public/application/components/tutorial/tutorial.js index 576f732278b8e4..8139bc6d38ab1d 100644 --- a/src/plugins/home/public/application/components/tutorial/tutorial.js +++ b/src/plugins/home/public/application/components/tutorial/tutorial.js @@ -334,6 +334,23 @@ class TutorialUi extends React.Component { } }; + renderModuleNotices() { + const notices = getServices().tutorialService.getModuleNotices(); + if (notices.length && this.state.tutorial.moduleName) { + return ( + + {notices.map((ModuleNotice, index) => ( + + + + ))} + + ); + } else { + return null; + } + } + render() { let content; if (this.state.notFound) { @@ -382,6 +399,7 @@ class TutorialUi extends React.Component { isBeta={this.state.tutorial.isBeta} /> + {this.renderModuleNotices()}

{this.renderInstructionSetsToggle()}
diff --git a/src/plugins/home/public/application/components/tutorial/tutorial.test.js b/src/plugins/home/public/application/components/tutorial/tutorial.test.js index 23b0dc50018c18..9944ac4848bc61 100644 --- a/src/plugins/home/public/application/components/tutorial/tutorial.test.js +++ b/src/plugins/home/public/application/components/tutorial/tutorial.test.js @@ -28,6 +28,9 @@ jest.mock('../../kibana_services', () => ({ chrome: { setBreadcrumbs: () => {}, }, + tutorialService: { + getModuleNotices: () => [], + }, }), })); jest.mock('../../../../../kibana_react/public', () => { diff --git a/src/plugins/home/public/application/components/tutorial_directory.js b/src/plugins/home/public/application/components/tutorial_directory.js index 774b23af11ac85..948024ae85dda4 100644 --- a/src/plugins/home/public/application/components/tutorial_directory.js +++ b/src/plugins/home/public/application/components/tutorial_directory.js @@ -30,6 +30,7 @@ import { EuiTab, EuiFlexItem, EuiFlexGrid, + EuiFlexGroup, EuiSpacer, EuiTitle, EuiPageBody, @@ -102,6 +103,7 @@ class TutorialDirectoryUi extends React.Component { this.state = { selectedTabId: openTab, tutorialCards: [], + notices: getServices().tutorialService.getDirectoryNotices(), }; } @@ -227,18 +229,62 @@ class TutorialDirectoryUi extends React.Component { ); }; + renderNotices = () => { + const notices = getServices().tutorialService.getDirectoryNotices(); + return notices.length ? ( + + {notices.map((DirectoryNotice, index) => ( + + + + ))} + + ) : null; + }; + + renderHeaderLinks = () => { + const headerLinks = getServices().tutorialService.getDirectoryHeaderLinks(); + return headerLinks.length ? ( + + {headerLinks.map((HeaderLink, index) => ( + + + + ))} + + ) : null; + }; + + renderHeader = () => { + const notices = this.renderNotices(); + const headerLinks = this.renderHeaderLinks(); + + return ( + <> + + + +

+ +

+
+
+ {headerLinks ? {headerLinks} : null} +
+ {notices} + + ); + }; + render() { return ( - -

- -

-
- + {this.renderHeader()} - {this.renderTabs()} {this.renderTabContent()} diff --git a/src/plugins/home/public/index.ts b/src/plugins/home/public/index.ts index 587dbe886d505b..dc48332e052de0 100644 --- a/src/plugins/home/public/index.ts +++ b/src/plugins/home/public/index.ts @@ -30,6 +30,9 @@ export { FeatureCatalogueCategory, Environment, TutorialVariables, + TutorialDirectoryNoticeComponent, + TutorialDirectoryHeaderLinkComponent, + TutorialModuleNoticeComponent, } from './services'; export * from '../common/instruction_variant'; import { HomePublicPlugin } from './plugin'; diff --git a/src/plugins/home/public/plugin.ts b/src/plugins/home/public/plugin.ts index d05fce652bd406..6859d916a61afd 100644 --- a/src/plugins/home/public/plugin.ts +++ b/src/plugins/home/public/plugin.ts @@ -104,7 +104,7 @@ export class HomePublicPlugin i18n.translate('home.pageTitle', { defaultMessage: 'Home' }) ); const { renderApp } = await import('./application'); - return await renderApp(params.element, params.history); + return await renderApp(params.element, coreStart, params.history); }, }); kibanaLegacy.forwardApp('home', 'home'); diff --git a/src/plugins/home/public/services/tutorials/index.ts b/src/plugins/home/public/services/tutorials/index.ts index 3de1e67204d966..44f0badd531b7c 100644 --- a/src/plugins/home/public/services/tutorials/index.ts +++ b/src/plugins/home/public/services/tutorials/index.ts @@ -17,4 +17,11 @@ * under the License. */ -export { TutorialService, TutorialVariables, TutorialServiceSetup } from './tutorial_service'; +export { + TutorialService, + TutorialVariables, + TutorialServiceSetup, + TutorialDirectoryNoticeComponent, + TutorialDirectoryHeaderLinkComponent, + TutorialModuleNoticeComponent, +} from './tutorial_service'; diff --git a/src/plugins/home/public/services/tutorials/tutorial_service.mock.ts b/src/plugins/home/public/services/tutorials/tutorial_service.mock.ts index bd604fb231dee2..667730e25a2e3e 100644 --- a/src/plugins/home/public/services/tutorials/tutorial_service.mock.ts +++ b/src/plugins/home/public/services/tutorials/tutorial_service.mock.ts @@ -22,6 +22,9 @@ import { TutorialService, TutorialServiceSetup } from './tutorial_service'; const createSetupMock = (): jest.Mocked => { const setup = { setVariable: jest.fn(), + registerDirectoryNotice: jest.fn(), + registerDirectoryHeaderLink: jest.fn(), + registerModuleNotice: jest.fn(), }; return setup; }; @@ -30,6 +33,9 @@ const createMock = (): jest.Mocked> => { const service = { setup: jest.fn(), getVariables: jest.fn(() => ({})), + getDirectoryNotices: jest.fn(() => []), + getDirectoryHeaderLinks: jest.fn(() => []), + getModuleNotices: jest.fn(() => []), }; service.setup.mockImplementation(createSetupMock); return service; diff --git a/src/plugins/home/public/services/tutorials/tutorial_service.test.ts b/src/plugins/home/public/services/tutorials/tutorial_service.test.ts deleted file mode 100644 index f4bcd71a39e8ae..00000000000000 --- a/src/plugins/home/public/services/tutorials/tutorial_service.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * 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 { TutorialService } from './tutorial_service'; - -describe('TutorialService', () => { - describe('setup', () => { - test('allows multiple set calls', () => { - const setup = new TutorialService().setup(); - expect(() => { - setup.setVariable('abc', 123); - setup.setVariable('def', 456); - }).not.toThrow(); - }); - - test('throws when same variable is set twice', () => { - const setup = new TutorialService().setup(); - expect(() => { - setup.setVariable('abc', 123); - setup.setVariable('abc', 456); - }).toThrow(); - }); - }); - - describe('getVariables', () => { - test('returns empty object', () => { - const service = new TutorialService(); - expect(service.getVariables()).toEqual({}); - }); - - test('returns last state of update calls', () => { - const service = new TutorialService(); - const setup = service.setup(); - setup.setVariable('abc', 123); - setup.setVariable('def', { subKey: 456 }); - expect(service.getVariables()).toEqual({ abc: 123, def: { subKey: 456 } }); - }); - }); -}); diff --git a/src/plugins/home/public/services/tutorials/tutorial_service.test.tsx b/src/plugins/home/public/services/tutorials/tutorial_service.test.tsx new file mode 100644 index 00000000000000..2a60550e39d90b --- /dev/null +++ b/src/plugins/home/public/services/tutorials/tutorial_service.test.tsx @@ -0,0 +1,151 @@ +/* + * 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 { TutorialService } from './tutorial_service'; + +describe('TutorialService', () => { + describe('setup', () => { + test('allows multiple set variable calls', () => { + const setup = new TutorialService().setup(); + expect(() => { + setup.setVariable('abc', 123); + setup.setVariable('def', 456); + }).not.toThrow(); + }); + + test('throws when same variable is set twice', () => { + const setup = new TutorialService().setup(); + expect(() => { + setup.setVariable('abc', 123); + setup.setVariable('abc', 456); + }).toThrow(); + }); + + test('allows multiple register directory notice calls', () => { + const setup = new TutorialService().setup(); + expect(() => { + setup.registerDirectoryNotice('abc', () =>
); + setup.registerDirectoryNotice('def', () => ); + }).not.toThrow(); + }); + + test('throws when same directory notice is registered twice', () => { + const setup = new TutorialService().setup(); + expect(() => { + setup.registerDirectoryNotice('abc', () =>
); + setup.registerDirectoryNotice('abc', () => ); + }).toThrow(); + }); + + test('allows multiple register directory header link calls', () => { + const setup = new TutorialService().setup(); + expect(() => { + setup.registerDirectoryHeaderLink('abc', () => 123); + setup.registerDirectoryHeaderLink('def', () => 456); + }).not.toThrow(); + }); + + test('throws when same directory header link is registered twice', () => { + const setup = new TutorialService().setup(); + expect(() => { + setup.registerDirectoryHeaderLink('abc', () => 123); + setup.registerDirectoryHeaderLink('abc', () => 456); + }).toThrow(); + }); + + test('allows multiple register module notice calls', () => { + const setup = new TutorialService().setup(); + expect(() => { + setup.registerModuleNotice('abc', () =>
); + setup.registerModuleNotice('def', () => ); + }).not.toThrow(); + }); + + test('throws when same module notice is registered twice', () => { + const setup = new TutorialService().setup(); + expect(() => { + setup.registerModuleNotice('abc', () =>
); + setup.registerModuleNotice('abc', () => ); + }).toThrow(); + }); + }); + + describe('getVariables', () => { + test('returns empty object', () => { + const service = new TutorialService(); + expect(service.getVariables()).toEqual({}); + }); + + test('returns last state of update calls', () => { + const service = new TutorialService(); + const setup = service.setup(); + setup.setVariable('abc', 123); + setup.setVariable('def', { subKey: 456 }); + expect(service.getVariables()).toEqual({ abc: 123, def: { subKey: 456 } }); + }); + }); + + describe('getDirectoryNotices', () => { + test('returns empty array', () => { + const service = new TutorialService(); + expect(service.getDirectoryNotices()).toEqual([]); + }); + + test('returns last state of register calls', () => { + const service = new TutorialService(); + const setup = service.setup(); + const notices = [() =>
, () => ]; + setup.registerDirectoryNotice('abc', notices[0]); + setup.registerDirectoryNotice('def', notices[1]); + expect(service.getDirectoryNotices()).toEqual(notices); + }); + }); + + describe('getDirectoryHeaderLinks', () => { + test('returns empty array', () => { + const service = new TutorialService(); + expect(service.getDirectoryHeaderLinks()).toEqual([]); + }); + + test('returns last state of register calls', () => { + const service = new TutorialService(); + const setup = service.setup(); + const links = [() => 123, () => 456]; + setup.registerDirectoryHeaderLink('abc', links[0]); + setup.registerDirectoryHeaderLink('def', links[1]); + expect(service.getDirectoryHeaderLinks()).toEqual(links); + }); + }); + + describe('getModuleNotices', () => { + test('returns empty array', () => { + const service = new TutorialService(); + expect(service.getModuleNotices()).toEqual([]); + }); + + test('returns last state of register calls', () => { + const service = new TutorialService(); + const setup = service.setup(); + const notices = [() =>
, () => ]; + setup.registerModuleNotice('abc', notices[0]); + setup.registerModuleNotice('def', notices[1]); + expect(service.getModuleNotices()).toEqual(notices); + }); + }); +}); diff --git a/src/plugins/home/public/services/tutorials/tutorial_service.ts b/src/plugins/home/public/services/tutorials/tutorial_service.ts index 38297a64373152..538cea1c704581 100644 --- a/src/plugins/home/public/services/tutorials/tutorial_service.ts +++ b/src/plugins/home/public/services/tutorials/tutorial_service.ts @@ -16,12 +16,29 @@ * specific language governing permissions and limitations * under the License. */ +import React from 'react'; /** @public */ export type TutorialVariables = Partial>; +/** @public */ +export type TutorialDirectoryNoticeComponent = React.FC; + +/** @public */ +export type TutorialDirectoryHeaderLinkComponent = React.FC; + +/** @public */ +export type TutorialModuleNoticeComponent = React.FC<{ + moduleName: string; +}>; + export class TutorialService { private tutorialVariables: TutorialVariables = {}; + private tutorialDirectoryNotices: { [key: string]: TutorialDirectoryNoticeComponent } = {}; + private tutorialDirectoryHeaderLinks: { + [key: string]: TutorialDirectoryHeaderLinkComponent; + } = {}; + private tutorialModuleNotices: { [key: string]: TutorialModuleNoticeComponent } = {}; public setup() { return { @@ -34,12 +51,57 @@ export class TutorialService { } this.tutorialVariables[key] = value; }, + + /** + * Registers a component that will be rendered at the top of tutorial directory page. + */ + registerDirectoryNotice: (id: string, component: TutorialDirectoryNoticeComponent) => { + if (this.tutorialDirectoryNotices[id]) { + throw new Error(`directory notice ${id} already set`); + } + this.tutorialDirectoryNotices[id] = component; + }, + + /** + * Registers a component that will be rendered next to tutorial directory title/header area. + */ + registerDirectoryHeaderLink: ( + id: string, + component: TutorialDirectoryHeaderLinkComponent + ) => { + if (this.tutorialDirectoryHeaderLinks[id]) { + throw new Error(`directory header link ${id} already set`); + } + this.tutorialDirectoryHeaderLinks[id] = component; + }, + + /** + * Registers a component that will be rendered in the description of a tutorial that is associated with a module. + */ + registerModuleNotice: (id: string, component: TutorialModuleNoticeComponent) => { + if (this.tutorialModuleNotices[id]) { + throw new Error(`module notice ${id} already set`); + } + this.tutorialModuleNotices[id] = component; + }, }; } public getVariables() { return this.tutorialVariables; } + + public getDirectoryNotices() { + return Object.values(this.tutorialDirectoryNotices); + } + + public getDirectoryHeaderLinks() { + return Object.values(this.tutorialDirectoryHeaderLinks); + } + + public getModuleNotices() { + return Object.values(this.tutorialModuleNotices); + } } export type TutorialServiceSetup = ReturnType; diff --git a/src/plugins/home/server/services/tutorials/lib/tutorial_schema.ts b/src/plugins/home/server/services/tutorials/lib/tutorial_schema.ts index 32e5483b8b0703..bf28212624a4d7 100644 --- a/src/plugins/home/server/services/tutorials/lib/tutorial_schema.ts +++ b/src/plugins/home/server/services/tutorials/lib/tutorial_schema.ts @@ -110,6 +110,7 @@ export const tutorialSchema = { .required(), category: Joi.string().valid(Object.values(TUTORIAL_CATEGORY)).required(), name: Joi.string().required(), + moduleName: Joi.string(), isBeta: Joi.boolean().default(false), shortDescription: Joi.string().required(), euiIconType: Joi.string(), // EUI icon type string, one of https://elastic.github.io/eui/#/icons diff --git a/src/plugins/home/server/services/tutorials/lib/tutorials_registry_types.ts b/src/plugins/home/server/services/tutorials/lib/tutorials_registry_types.ts index 210d563696667b..a6b70cd70c02d7 100644 --- a/src/plugins/home/server/services/tutorials/lib/tutorials_registry_types.ts +++ b/src/plugins/home/server/services/tutorials/lib/tutorials_registry_types.ts @@ -80,6 +80,7 @@ export interface TutorialSchema { id: string; category: TutorialsCategory; name: string; + moduleName?: string; isBeta?: boolean; shortDescription: string; euiIconType?: IconType; // EUI icon type string, one of https://elastic.github.io/eui/#/display/icons; diff --git a/src/plugins/home/server/services/tutorials/tutorials_registry.test.ts b/src/plugins/home/server/services/tutorials/tutorials_registry.test.ts index 8144fef2d92e46..b91a265da7d18f 100644 --- a/src/plugins/home/server/services/tutorials/tutorials_registry.test.ts +++ b/src/plugins/home/server/services/tutorials/tutorials_registry.test.ts @@ -54,6 +54,7 @@ const VALID_TUTORIAL: TutorialSchema = { id: 'test', category: 'logging' as TutorialsCategory, name: 'new tutorial provider', + moduleName: 'test', isBeta: false, shortDescription: 'short description', euiIconType: 'alert', diff --git a/src/plugins/home/server/tutorials/activemq_logs/index.ts b/src/plugins/home/server/tutorials/activemq_logs/index.ts index e85100996d4a16..c11c070637ae1b 100644 --- a/src/plugins/home/server/tutorials/activemq_logs/index.ts +++ b/src/plugins/home/server/tutorials/activemq_logs/index.ts @@ -37,6 +37,7 @@ export function activemqLogsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.activemqLogs.nameTitle', { defaultMessage: 'ActiveMQ logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.activemqLogs.shortDescription', { defaultMessage: 'Collect ActiveMQ logs with Filebeat.', diff --git a/src/plugins/home/server/tutorials/activemq_metrics/index.ts b/src/plugins/home/server/tutorials/activemq_metrics/index.ts index 088c5db4c6137a..e00ffb4773bea8 100644 --- a/src/plugins/home/server/tutorials/activemq_metrics/index.ts +++ b/src/plugins/home/server/tutorials/activemq_metrics/index.ts @@ -36,6 +36,7 @@ export function activemqMetricsSpecProvider(context: TutorialContext): TutorialS name: i18n.translate('home.tutorials.activemqMetrics.nameTitle', { defaultMessage: 'ActiveMQ metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.activemqMetrics.shortDescription', { defaultMessage: 'Fetch monitoring metrics from ActiveMQ instances.', diff --git a/src/plugins/home/server/tutorials/aerospike_metrics/index.ts b/src/plugins/home/server/tutorials/aerospike_metrics/index.ts index 58ab2dcf0986f9..c65022c1875c4c 100644 --- a/src/plugins/home/server/tutorials/aerospike_metrics/index.ts +++ b/src/plugins/home/server/tutorials/aerospike_metrics/index.ts @@ -36,6 +36,7 @@ export function aerospikeMetricsSpecProvider(context: TutorialContext): Tutorial name: i18n.translate('home.tutorials.aerospikeMetrics.nameTitle', { defaultMessage: 'Aerospike metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.aerospikeMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/apache_logs/index.ts b/src/plugins/home/server/tutorials/apache_logs/index.ts index 434f0b0b83f98b..94fa9ad1258ec0 100644 --- a/src/plugins/home/server/tutorials/apache_logs/index.ts +++ b/src/plugins/home/server/tutorials/apache_logs/index.ts @@ -37,6 +37,7 @@ export function apacheLogsSpecProvider(context: TutorialContext): TutorialSchema name: i18n.translate('home.tutorials.apacheLogs.nameTitle', { defaultMessage: 'Apache logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.apacheLogs.shortDescription', { defaultMessage: 'Collect and parse access and error logs created by the Apache HTTP server.', diff --git a/src/plugins/home/server/tutorials/apache_metrics/index.ts b/src/plugins/home/server/tutorials/apache_metrics/index.ts index 1521c9820c400e..91de90b9f6c6b2 100644 --- a/src/plugins/home/server/tutorials/apache_metrics/index.ts +++ b/src/plugins/home/server/tutorials/apache_metrics/index.ts @@ -36,6 +36,7 @@ export function apacheMetricsSpecProvider(context: TutorialContext): TutorialSch name: i18n.translate('home.tutorials.apacheMetrics.nameTitle', { defaultMessage: 'Apache metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.apacheMetrics.shortDescription', { defaultMessage: 'Fetch internal metrics from the Apache 2 HTTP server.', diff --git a/src/plugins/home/server/tutorials/auditbeat/index.ts b/src/plugins/home/server/tutorials/auditbeat/index.ts index 214fda5a7cc538..44a97bfce6cef5 100644 --- a/src/plugins/home/server/tutorials/auditbeat/index.ts +++ b/src/plugins/home/server/tutorials/auditbeat/index.ts @@ -31,11 +31,13 @@ import { export function auditbeatSpecProvider(context: TutorialContext): TutorialSchema { const platforms = ['OSX', 'DEB', 'RPM', 'WINDOWS'] as const; + const moduleName = 'auditbeat'; return { id: 'auditbeat', name: i18n.translate('home.tutorials.auditbeat.nameTitle', { defaultMessage: 'Auditbeat', }), + moduleName, category: TutorialsCategory.SECURITY_SOLUTION, shortDescription: i18n.translate('home.tutorials.auditbeat.shortDescription', { defaultMessage: 'Collect audit data from your hosts.', diff --git a/src/plugins/home/server/tutorials/aws_logs/index.ts b/src/plugins/home/server/tutorials/aws_logs/index.ts index 2fa22fa2c2d700..b875d93952c7a4 100644 --- a/src/plugins/home/server/tutorials/aws_logs/index.ts +++ b/src/plugins/home/server/tutorials/aws_logs/index.ts @@ -37,6 +37,7 @@ export function awsLogsSpecProvider(context: TutorialContext): TutorialSchema { name: i18n.translate('home.tutorials.awsLogs.nameTitle', { defaultMessage: 'AWS S3 based logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.awsLogs.shortDescription', { defaultMessage: 'Collect AWS logs from S3 bucket with Filebeat.', diff --git a/src/plugins/home/server/tutorials/aws_metrics/index.ts b/src/plugins/home/server/tutorials/aws_metrics/index.ts index c52620e150b5fe..549e98280bef22 100644 --- a/src/plugins/home/server/tutorials/aws_metrics/index.ts +++ b/src/plugins/home/server/tutorials/aws_metrics/index.ts @@ -36,6 +36,7 @@ export function awsMetricsSpecProvider(context: TutorialContext): TutorialSchema name: i18n.translate('home.tutorials.awsMetrics.nameTitle', { defaultMessage: 'AWS metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.awsMetrics.shortDescription', { defaultMessage: diff --git a/src/plugins/home/server/tutorials/azure_logs/index.ts b/src/plugins/home/server/tutorials/azure_logs/index.ts index 06aef411775f1a..3624bea96b465c 100644 --- a/src/plugins/home/server/tutorials/azure_logs/index.ts +++ b/src/plugins/home/server/tutorials/azure_logs/index.ts @@ -37,6 +37,7 @@ export function azureLogsSpecProvider(context: TutorialContext): TutorialSchema name: i18n.translate('home.tutorials.azureLogs.nameTitle', { defaultMessage: 'Azure logs', }), + moduleName, isBeta: true, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.azureLogs.shortDescription', { diff --git a/src/plugins/home/server/tutorials/azure_metrics/index.ts b/src/plugins/home/server/tutorials/azure_metrics/index.ts index c11b3ac0139bae..ac92d70fc64f5f 100644 --- a/src/plugins/home/server/tutorials/azure_metrics/index.ts +++ b/src/plugins/home/server/tutorials/azure_metrics/index.ts @@ -36,6 +36,7 @@ export function azureMetricsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.azureMetrics.nameTitle', { defaultMessage: 'Azure metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.azureMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/ceph_metrics/index.ts b/src/plugins/home/server/tutorials/ceph_metrics/index.ts index 968a0a3f66b0a3..71e540454bc3a7 100644 --- a/src/plugins/home/server/tutorials/ceph_metrics/index.ts +++ b/src/plugins/home/server/tutorials/ceph_metrics/index.ts @@ -36,6 +36,7 @@ export function cephMetricsSpecProvider(context: TutorialContext): TutorialSchem name: i18n.translate('home.tutorials.cephMetrics.nameTitle', { defaultMessage: 'Ceph metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.cephMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/cisco_logs/index.ts b/src/plugins/home/server/tutorials/cisco_logs/index.ts index 2322f503b80ce5..b771744a069c3d 100644 --- a/src/plugins/home/server/tutorials/cisco_logs/index.ts +++ b/src/plugins/home/server/tutorials/cisco_logs/index.ts @@ -37,6 +37,7 @@ export function ciscoLogsSpecProvider(context: TutorialContext): TutorialSchema name: i18n.translate('home.tutorials.ciscoLogs.nameTitle', { defaultMessage: 'Cisco', }), + moduleName, category: TutorialsCategory.SECURITY_SOLUTION, shortDescription: i18n.translate('home.tutorials.ciscoLogs.shortDescription', { defaultMessage: 'Collect and parse logs received from Cisco ASA firewalls.', diff --git a/src/plugins/home/server/tutorials/cloudwatch_logs/index.ts b/src/plugins/home/server/tutorials/cloudwatch_logs/index.ts index 9d33d9bf786d01..fb7b07c5dc1af2 100644 --- a/src/plugins/home/server/tutorials/cloudwatch_logs/index.ts +++ b/src/plugins/home/server/tutorials/cloudwatch_logs/index.ts @@ -30,11 +30,13 @@ import { } from '../../services/tutorials/lib/tutorials_registry_types'; export function cloudwatchLogsSpecProvider(context: TutorialContext): TutorialSchema { + const moduleName = 'aws'; return { id: 'cloudwatchLogs', name: i18n.translate('home.tutorials.cloudwatchLogs.nameTitle', { defaultMessage: 'AWS Cloudwatch logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.cloudwatchLogs.shortDescription', { defaultMessage: 'Collect Cloudwatch logs with Functionbeat.', diff --git a/src/plugins/home/server/tutorials/cockroachdb_metrics/index.ts b/src/plugins/home/server/tutorials/cockroachdb_metrics/index.ts index 96c02f24e347ae..1cb318c83bd34b 100644 --- a/src/plugins/home/server/tutorials/cockroachdb_metrics/index.ts +++ b/src/plugins/home/server/tutorials/cockroachdb_metrics/index.ts @@ -36,6 +36,7 @@ export function cockroachdbMetricsSpecProvider(context: TutorialContext): Tutori name: i18n.translate('home.tutorials.cockroachdbMetrics.nameTitle', { defaultMessage: 'CockroachDB metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.cockroachdbMetrics.shortDescription', { defaultMessage: 'Fetch monitoring metrics from the CockroachDB server.', diff --git a/src/plugins/home/server/tutorials/consul_metrics/index.ts b/src/plugins/home/server/tutorials/consul_metrics/index.ts index 8bf4333cb018f5..e389db502a769f 100644 --- a/src/plugins/home/server/tutorials/consul_metrics/index.ts +++ b/src/plugins/home/server/tutorials/consul_metrics/index.ts @@ -36,6 +36,7 @@ export function consulMetricsSpecProvider(context: TutorialContext): TutorialSch name: i18n.translate('home.tutorials.consulMetrics.nameTitle', { defaultMessage: 'Consul metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.consulMetrics.shortDescription', { defaultMessage: 'Fetch monitoring metrics from the Consul server.', diff --git a/src/plugins/home/server/tutorials/coredns_logs/index.ts b/src/plugins/home/server/tutorials/coredns_logs/index.ts index 4304fb7acb9079..7fc8a2402d2167 100644 --- a/src/plugins/home/server/tutorials/coredns_logs/index.ts +++ b/src/plugins/home/server/tutorials/coredns_logs/index.ts @@ -37,6 +37,7 @@ export function corednsLogsSpecProvider(context: TutorialContext): TutorialSchem name: i18n.translate('home.tutorials.corednsLogs.nameTitle', { defaultMessage: 'CoreDNS logs', }), + moduleName, category: TutorialsCategory.SECURITY_SOLUTION, shortDescription: i18n.translate('home.tutorials.corednsLogs.shortDescription', { defaultMessage: 'Collect the logs created by Coredns.', diff --git a/src/plugins/home/server/tutorials/coredns_metrics/index.ts b/src/plugins/home/server/tutorials/coredns_metrics/index.ts index 44bd0cb3999f65..c6589715ba9ce4 100644 --- a/src/plugins/home/server/tutorials/coredns_metrics/index.ts +++ b/src/plugins/home/server/tutorials/coredns_metrics/index.ts @@ -36,6 +36,7 @@ export function corednsMetricsSpecProvider(context: TutorialContext): TutorialSc name: i18n.translate('home.tutorials.corednsMetrics.nameTitle', { defaultMessage: 'CoreDNS metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.corednsMetrics.shortDescription', { defaultMessage: 'Fetch monitoring metrics from the CoreDNS server.', diff --git a/src/plugins/home/server/tutorials/couchbase_metrics/index.ts b/src/plugins/home/server/tutorials/couchbase_metrics/index.ts index efd59029c9c502..370541c9324d8e 100644 --- a/src/plugins/home/server/tutorials/couchbase_metrics/index.ts +++ b/src/plugins/home/server/tutorials/couchbase_metrics/index.ts @@ -36,6 +36,7 @@ export function couchbaseMetricsSpecProvider(context: TutorialContext): Tutorial name: i18n.translate('home.tutorials.couchbaseMetrics.nameTitle', { defaultMessage: 'Couchbase metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.couchbaseMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/couchdb_metrics/index.ts b/src/plugins/home/server/tutorials/couchdb_metrics/index.ts index 1fbaa448172262..8d70fcf2a6cd72 100644 --- a/src/plugins/home/server/tutorials/couchdb_metrics/index.ts +++ b/src/plugins/home/server/tutorials/couchdb_metrics/index.ts @@ -36,6 +36,7 @@ export function couchdbMetricsSpecProvider(context: TutorialContext): TutorialSc name: i18n.translate('home.tutorials.couchdbMetrics.nameTitle', { defaultMessage: 'CouchDB metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.couchdbMetrics.shortDescription', { defaultMessage: 'Fetch monitoring metrics from the CouchdB server.', diff --git a/src/plugins/home/server/tutorials/docker_metrics/index.ts b/src/plugins/home/server/tutorials/docker_metrics/index.ts index 8c603697c47136..2e0c3ccb642dd8 100644 --- a/src/plugins/home/server/tutorials/docker_metrics/index.ts +++ b/src/plugins/home/server/tutorials/docker_metrics/index.ts @@ -36,6 +36,7 @@ export function dockerMetricsSpecProvider(context: TutorialContext): TutorialSch name: i18n.translate('home.tutorials.dockerMetrics.nameTitle', { defaultMessage: 'Docker metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.dockerMetrics.shortDescription', { defaultMessage: 'Fetch metrics about your Docker containers.', diff --git a/src/plugins/home/server/tutorials/dropwizard_metrics/index.ts b/src/plugins/home/server/tutorials/dropwizard_metrics/index.ts index 008a7a9b3a6970..d74db4b2ad9580 100644 --- a/src/plugins/home/server/tutorials/dropwizard_metrics/index.ts +++ b/src/plugins/home/server/tutorials/dropwizard_metrics/index.ts @@ -36,6 +36,7 @@ export function dropwizardMetricsSpecProvider(context: TutorialContext): Tutoria name: i18n.translate('home.tutorials.dropwizardMetrics.nameTitle', { defaultMessage: 'Dropwizard metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.dropwizardMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/elasticsearch_logs/index.ts b/src/plugins/home/server/tutorials/elasticsearch_logs/index.ts index 515b06ea82a5ef..f6c280d29f67fc 100644 --- a/src/plugins/home/server/tutorials/elasticsearch_logs/index.ts +++ b/src/plugins/home/server/tutorials/elasticsearch_logs/index.ts @@ -37,6 +37,7 @@ export function elasticsearchLogsSpecProvider(context: TutorialContext): Tutoria name: i18n.translate('home.tutorials.elasticsearchLogs.nameTitle', { defaultMessage: 'Elasticsearch logs', }), + moduleName, category: TutorialsCategory.LOGGING, isBeta: true, shortDescription: i18n.translate('home.tutorials.elasticsearchLogs.shortDescription', { diff --git a/src/plugins/home/server/tutorials/elasticsearch_metrics/index.ts b/src/plugins/home/server/tutorials/elasticsearch_metrics/index.ts index ea6dcf86d23e21..38713056e0640a 100644 --- a/src/plugins/home/server/tutorials/elasticsearch_metrics/index.ts +++ b/src/plugins/home/server/tutorials/elasticsearch_metrics/index.ts @@ -36,6 +36,7 @@ export function elasticsearchMetricsSpecProvider(context: TutorialContext): Tuto name: i18n.translate('home.tutorials.elasticsearchMetrics.nameTitle', { defaultMessage: 'Elasticsearch metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.elasticsearchMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/envoyproxy_logs/index.ts b/src/plugins/home/server/tutorials/envoyproxy_logs/index.ts index a9b9c33d61bdf1..0cf032e6b90c15 100644 --- a/src/plugins/home/server/tutorials/envoyproxy_logs/index.ts +++ b/src/plugins/home/server/tutorials/envoyproxy_logs/index.ts @@ -37,6 +37,7 @@ export function envoyproxyLogsSpecProvider(context: TutorialContext): TutorialSc name: i18n.translate('home.tutorials.envoyproxyLogs.nameTitle', { defaultMessage: 'Envoyproxy', }), + moduleName, category: TutorialsCategory.SECURITY_SOLUTION, shortDescription: i18n.translate('home.tutorials.envoyproxyLogs.shortDescription', { defaultMessage: 'Collect and parse logs received from the Envoy proxy.', diff --git a/src/plugins/home/server/tutorials/envoyproxy_metrics/index.ts b/src/plugins/home/server/tutorials/envoyproxy_metrics/index.ts index adc7a494200c17..9b453370fb8026 100644 --- a/src/plugins/home/server/tutorials/envoyproxy_metrics/index.ts +++ b/src/plugins/home/server/tutorials/envoyproxy_metrics/index.ts @@ -36,6 +36,7 @@ export function envoyproxyMetricsSpecProvider(context: TutorialContext): Tutoria name: i18n.translate('home.tutorials.envoyproxyMetrics.nameTitle', { defaultMessage: 'Envoy Proxy metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.envoyproxyMetrics.shortDescription', { defaultMessage: 'Fetch monitoring metrics from Envoy Proxy.', diff --git a/src/plugins/home/server/tutorials/etcd_metrics/index.ts b/src/plugins/home/server/tutorials/etcd_metrics/index.ts index 2956473b6643bf..48bdba5abb4b34 100644 --- a/src/plugins/home/server/tutorials/etcd_metrics/index.ts +++ b/src/plugins/home/server/tutorials/etcd_metrics/index.ts @@ -36,6 +36,7 @@ export function etcdMetricsSpecProvider(context: TutorialContext): TutorialSchem name: i18n.translate('home.tutorials.etcdMetrics.nameTitle', { defaultMessage: 'Etcd metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.etcdMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/golang_metrics/index.ts b/src/plugins/home/server/tutorials/golang_metrics/index.ts index c53f8b2bba2817..e5ecbb9eb583b3 100644 --- a/src/plugins/home/server/tutorials/golang_metrics/index.ts +++ b/src/plugins/home/server/tutorials/golang_metrics/index.ts @@ -36,6 +36,7 @@ export function golangMetricsSpecProvider(context: TutorialContext): TutorialSch name: i18n.translate('home.tutorials.golangMetrics.nameTitle', { defaultMessage: 'Golang metrics', }), + moduleName, isBeta: true, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.golangMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/googlecloud_metrics/index.ts b/src/plugins/home/server/tutorials/googlecloud_metrics/index.ts index 504ede04c12d8e..42dc0720c10e0d 100644 --- a/src/plugins/home/server/tutorials/googlecloud_metrics/index.ts +++ b/src/plugins/home/server/tutorials/googlecloud_metrics/index.ts @@ -36,6 +36,7 @@ export function googlecloudMetricsSpecProvider(context: TutorialContext): Tutori name: i18n.translate('home.tutorials.googlecloudMetrics.nameTitle', { defaultMessage: 'Google Cloud metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.googlecloudMetrics.shortDescription', { defaultMessage: diff --git a/src/plugins/home/server/tutorials/haproxy_metrics/index.ts b/src/plugins/home/server/tutorials/haproxy_metrics/index.ts index f06dfaa93063c0..49e2ec4390db9f 100644 --- a/src/plugins/home/server/tutorials/haproxy_metrics/index.ts +++ b/src/plugins/home/server/tutorials/haproxy_metrics/index.ts @@ -36,6 +36,7 @@ export function haproxyMetricsSpecProvider(context: TutorialContext): TutorialSc name: i18n.translate('home.tutorials.haproxyMetrics.nameTitle', { defaultMessage: 'HAProxy metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.haproxyMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/ibmmq_logs/index.ts b/src/plugins/home/server/tutorials/ibmmq_logs/index.ts index 5739c03954def0..8f67b88c3fcf2b 100644 --- a/src/plugins/home/server/tutorials/ibmmq_logs/index.ts +++ b/src/plugins/home/server/tutorials/ibmmq_logs/index.ts @@ -37,6 +37,7 @@ export function ibmmqLogsSpecProvider(context: TutorialContext): TutorialSchema name: i18n.translate('home.tutorials.ibmmqLogs.nameTitle', { defaultMessage: 'IBM MQ logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.ibmmqLogs.shortDescription', { defaultMessage: 'Collect IBM MQ logs with Filebeat.', diff --git a/src/plugins/home/server/tutorials/ibmmq_metrics/index.ts b/src/plugins/home/server/tutorials/ibmmq_metrics/index.ts index 4f20b2d0684fc9..dc941233b02333 100644 --- a/src/plugins/home/server/tutorials/ibmmq_metrics/index.ts +++ b/src/plugins/home/server/tutorials/ibmmq_metrics/index.ts @@ -36,6 +36,7 @@ export function ibmmqMetricsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.ibmmqMetrics.nameTitle', { defaultMessage: 'IBM MQ metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.ibmmqMetrics.shortDescription', { defaultMessage: 'Fetch monitoring metrics from IBM MQ instances.', diff --git a/src/plugins/home/server/tutorials/iis_logs/index.ts b/src/plugins/home/server/tutorials/iis_logs/index.ts index fee8d036db757e..12411fc792e64e 100644 --- a/src/plugins/home/server/tutorials/iis_logs/index.ts +++ b/src/plugins/home/server/tutorials/iis_logs/index.ts @@ -37,6 +37,7 @@ export function iisLogsSpecProvider(context: TutorialContext): TutorialSchema { name: i18n.translate('home.tutorials.iisLogs.nameTitle', { defaultMessage: 'IIS logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.iisLogs.shortDescription', { defaultMessage: 'Collect and parse access and error logs created by the IIS HTTP server.', diff --git a/src/plugins/home/server/tutorials/iis_metrics/index.ts b/src/plugins/home/server/tutorials/iis_metrics/index.ts index 46621677a67ceb..d6dc5a2e33704d 100644 --- a/src/plugins/home/server/tutorials/iis_metrics/index.ts +++ b/src/plugins/home/server/tutorials/iis_metrics/index.ts @@ -36,6 +36,7 @@ export function iisMetricsSpecProvider(context: TutorialContext): TutorialSchema name: i18n.translate('home.tutorials.iisMetrics.nameTitle', { defaultMessage: 'IIS Metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.iisMetrics.shortDescription', { defaultMessage: 'Collect IIS server related metrics.', diff --git a/src/plugins/home/server/tutorials/iptables_logs/index.ts b/src/plugins/home/server/tutorials/iptables_logs/index.ts index fd84894dae8508..b3be1337674476 100644 --- a/src/plugins/home/server/tutorials/iptables_logs/index.ts +++ b/src/plugins/home/server/tutorials/iptables_logs/index.ts @@ -37,6 +37,7 @@ export function iptablesLogsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.iptablesLogs.nameTitle', { defaultMessage: 'Iptables / Ubiquiti', }), + moduleName, category: TutorialsCategory.SECURITY_SOLUTION, shortDescription: i18n.translate('home.tutorials.iptablesLogs.shortDescription', { defaultMessage: 'Collect and parse iptables and ip6tables logs or from Ubiqiti firewalls.', diff --git a/src/plugins/home/server/tutorials/kafka_logs/index.ts b/src/plugins/home/server/tutorials/kafka_logs/index.ts index 746e65b71008c1..aac172520829c3 100644 --- a/src/plugins/home/server/tutorials/kafka_logs/index.ts +++ b/src/plugins/home/server/tutorials/kafka_logs/index.ts @@ -37,6 +37,7 @@ export function kafkaLogsSpecProvider(context: TutorialContext): TutorialSchema name: i18n.translate('home.tutorials.kafkaLogs.nameTitle', { defaultMessage: 'Kafka logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.kafkaLogs.shortDescription', { defaultMessage: 'Collect and parse logs created by Kafka.', diff --git a/src/plugins/home/server/tutorials/kafka_metrics/index.ts b/src/plugins/home/server/tutorials/kafka_metrics/index.ts index 55860a3ab649a4..1b0ce44db65503 100644 --- a/src/plugins/home/server/tutorials/kafka_metrics/index.ts +++ b/src/plugins/home/server/tutorials/kafka_metrics/index.ts @@ -36,6 +36,7 @@ export function kafkaMetricsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.kafkaMetrics.nameTitle', { defaultMessage: 'Kafka metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.kafkaMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/kibana_metrics/index.ts b/src/plugins/home/server/tutorials/kibana_metrics/index.ts index fa966ac724a734..d595859959aca3 100644 --- a/src/plugins/home/server/tutorials/kibana_metrics/index.ts +++ b/src/plugins/home/server/tutorials/kibana_metrics/index.ts @@ -36,6 +36,7 @@ export function kibanaMetricsSpecProvider(context: TutorialContext): TutorialSch name: i18n.translate('home.tutorials.kibanaMetrics.nameTitle', { defaultMessage: 'Kibana metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.kibanaMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/kubernetes_metrics/index.ts b/src/plugins/home/server/tutorials/kubernetes_metrics/index.ts index bcea7f1221e1f1..a4ce9cfab5f627 100644 --- a/src/plugins/home/server/tutorials/kubernetes_metrics/index.ts +++ b/src/plugins/home/server/tutorials/kubernetes_metrics/index.ts @@ -36,6 +36,7 @@ export function kubernetesMetricsSpecProvider(context: TutorialContext): Tutoria name: i18n.translate('home.tutorials.kubernetesMetrics.nameTitle', { defaultMessage: 'Kubernetes metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.kubernetesMetrics.shortDescription', { defaultMessage: 'Fetch metrics from your Kubernetes installation.', diff --git a/src/plugins/home/server/tutorials/logstash_logs/index.ts b/src/plugins/home/server/tutorials/logstash_logs/index.ts index 69e498ac59459d..32982cd1055a4c 100644 --- a/src/plugins/home/server/tutorials/logstash_logs/index.ts +++ b/src/plugins/home/server/tutorials/logstash_logs/index.ts @@ -37,6 +37,7 @@ export function logstashLogsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.logstashLogs.nameTitle', { defaultMessage: 'Logstash logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.logstashLogs.shortDescription', { defaultMessage: 'Collect and parse debug and slow logs created by Logstash itself.', diff --git a/src/plugins/home/server/tutorials/logstash_metrics/index.ts b/src/plugins/home/server/tutorials/logstash_metrics/index.ts index 383273a8c365cc..11272b7ceef6bd 100644 --- a/src/plugins/home/server/tutorials/logstash_metrics/index.ts +++ b/src/plugins/home/server/tutorials/logstash_metrics/index.ts @@ -36,6 +36,7 @@ export function logstashMetricsSpecProvider(context: TutorialContext): TutorialS name: i18n.translate('home.tutorials.logstashMetrics.nameTitle', { defaultMessage: 'Logstash metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.logstashMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/memcached_metrics/index.ts b/src/plugins/home/server/tutorials/memcached_metrics/index.ts index 94451556ad34c6..c724b790f84a6c 100644 --- a/src/plugins/home/server/tutorials/memcached_metrics/index.ts +++ b/src/plugins/home/server/tutorials/memcached_metrics/index.ts @@ -36,6 +36,7 @@ export function memcachedMetricsSpecProvider(context: TutorialContext): Tutorial name: i18n.translate('home.tutorials.memcachedMetrics.nameTitle', { defaultMessage: 'Memcached metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.memcachedMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/mongodb_metrics/index.ts b/src/plugins/home/server/tutorials/mongodb_metrics/index.ts index f02695e207dd32..2f39a048f2f154 100644 --- a/src/plugins/home/server/tutorials/mongodb_metrics/index.ts +++ b/src/plugins/home/server/tutorials/mongodb_metrics/index.ts @@ -36,6 +36,7 @@ export function mongodbMetricsSpecProvider(context: TutorialContext): TutorialSc name: i18n.translate('home.tutorials.mongodbMetrics.nameTitle', { defaultMessage: 'MongoDB metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.mongodbMetrics.shortDescription', { defaultMessage: 'Fetch internal metrics from MongoDB.', diff --git a/src/plugins/home/server/tutorials/mssql_metrics/index.ts b/src/plugins/home/server/tutorials/mssql_metrics/index.ts index 4b418587f78b2c..1a1f047a128481 100644 --- a/src/plugins/home/server/tutorials/mssql_metrics/index.ts +++ b/src/plugins/home/server/tutorials/mssql_metrics/index.ts @@ -36,6 +36,7 @@ export function mssqlMetricsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.mssqlMetrics.nameTitle', { defaultMessage: 'Microsoft SQL Server Metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.mssqlMetrics.shortDescription', { defaultMessage: 'Fetch monitoring metrics from a Microsoft SQL Server instance', diff --git a/src/plugins/home/server/tutorials/munin_metrics/index.ts b/src/plugins/home/server/tutorials/munin_metrics/index.ts index 3dbb34cb22031e..8434d916daa1f6 100644 --- a/src/plugins/home/server/tutorials/munin_metrics/index.ts +++ b/src/plugins/home/server/tutorials/munin_metrics/index.ts @@ -36,6 +36,7 @@ export function muninMetricsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.muninMetrics.nameTitle', { defaultMessage: 'Munin metrics', }), + moduleName, euiIconType: '/plugins/home/assets/logos/munin.svg', isBeta: true, category: TutorialsCategory.METRICS, diff --git a/src/plugins/home/server/tutorials/mysql_logs/index.ts b/src/plugins/home/server/tutorials/mysql_logs/index.ts index 178a371f9212ed..37bbf409b91c5b 100644 --- a/src/plugins/home/server/tutorials/mysql_logs/index.ts +++ b/src/plugins/home/server/tutorials/mysql_logs/index.ts @@ -37,6 +37,7 @@ export function mysqlLogsSpecProvider(context: TutorialContext): TutorialSchema name: i18n.translate('home.tutorials.mysqlLogs.nameTitle', { defaultMessage: 'MySQL logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.mysqlLogs.shortDescription', { defaultMessage: 'Collect and parse error and slow logs created by MySQL.', diff --git a/src/plugins/home/server/tutorials/mysql_metrics/index.ts b/src/plugins/home/server/tutorials/mysql_metrics/index.ts index 1148caeb441f88..89f5edf22a7b65 100644 --- a/src/plugins/home/server/tutorials/mysql_metrics/index.ts +++ b/src/plugins/home/server/tutorials/mysql_metrics/index.ts @@ -36,6 +36,7 @@ export function mysqlMetricsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.mysqlMetrics.nameTitle', { defaultMessage: 'MySQL metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.mysqlMetrics.shortDescription', { defaultMessage: 'Fetch internal metrics from MySQL.', diff --git a/src/plugins/home/server/tutorials/nats_logs/index.ts b/src/plugins/home/server/tutorials/nats_logs/index.ts index 17c37755b6bc37..f00ddd6ca88793 100644 --- a/src/plugins/home/server/tutorials/nats_logs/index.ts +++ b/src/plugins/home/server/tutorials/nats_logs/index.ts @@ -37,6 +37,7 @@ export function natsLogsSpecProvider(context: TutorialContext): TutorialSchema { name: i18n.translate('home.tutorials.natsLogs.nameTitle', { defaultMessage: 'NATS logs', }), + moduleName, category: TutorialsCategory.LOGGING, isBeta: true, shortDescription: i18n.translate('home.tutorials.natsLogs.shortDescription', { diff --git a/src/plugins/home/server/tutorials/nats_metrics/index.ts b/src/plugins/home/server/tutorials/nats_metrics/index.ts index bce08e85c6977e..cda011297d2c67 100644 --- a/src/plugins/home/server/tutorials/nats_metrics/index.ts +++ b/src/plugins/home/server/tutorials/nats_metrics/index.ts @@ -36,6 +36,7 @@ export function natsMetricsSpecProvider(context: TutorialContext): TutorialSchem name: i18n.translate('home.tutorials.natsMetrics.nameTitle', { defaultMessage: 'NATS metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.natsMetrics.shortDescription', { defaultMessage: 'Fetch monitoring metrics from the Nats server.', diff --git a/src/plugins/home/server/tutorials/netflow/index.ts b/src/plugins/home/server/tutorials/netflow/index.ts index ec0aa8953b146f..5be30bbb152b73 100644 --- a/src/plugins/home/server/tutorials/netflow/index.ts +++ b/src/plugins/home/server/tutorials/netflow/index.ts @@ -25,9 +25,11 @@ import { createElasticCloudInstructions } from './elastic_cloud'; import { createOnPremElasticCloudInstructions } from './on_prem_elastic_cloud'; export function netflowSpecProvider() { + const moduleName = 'netflow'; return { id: 'netflow', name: 'Netflow', + moduleName, category: TutorialsCategory.SECURITY_SOLUTION, shortDescription: i18n.translate('home.tutorials.netflow.tutorialShortDescription', { defaultMessage: 'Collect Netflow records sent by a Netflow exporter.', diff --git a/src/plugins/home/server/tutorials/nginx_logs/index.ts b/src/plugins/home/server/tutorials/nginx_logs/index.ts index 37d0cc106bfe58..f357e77fc25ca3 100644 --- a/src/plugins/home/server/tutorials/nginx_logs/index.ts +++ b/src/plugins/home/server/tutorials/nginx_logs/index.ts @@ -37,6 +37,7 @@ export function nginxLogsSpecProvider(context: TutorialContext): TutorialSchema name: i18n.translate('home.tutorials.nginxLogs.nameTitle', { defaultMessage: 'Nginx logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.nginxLogs.shortDescription', { defaultMessage: 'Collect and parse access and error logs created by the Nginx HTTP server.', diff --git a/src/plugins/home/server/tutorials/nginx_metrics/index.ts b/src/plugins/home/server/tutorials/nginx_metrics/index.ts index 8671f7218ffc8d..09031883cef1c4 100644 --- a/src/plugins/home/server/tutorials/nginx_metrics/index.ts +++ b/src/plugins/home/server/tutorials/nginx_metrics/index.ts @@ -36,6 +36,7 @@ export function nginxMetricsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.nginxMetrics.nameTitle', { defaultMessage: 'Nginx metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.nginxMetrics.shortDescription', { defaultMessage: 'Fetch internal metrics from the Nginx HTTP server.', diff --git a/src/plugins/home/server/tutorials/openmetrics_metrics/index.ts b/src/plugins/home/server/tutorials/openmetrics_metrics/index.ts index eb539e15c1bcd9..197821f24dddb2 100644 --- a/src/plugins/home/server/tutorials/openmetrics_metrics/index.ts +++ b/src/plugins/home/server/tutorials/openmetrics_metrics/index.ts @@ -36,6 +36,7 @@ export function openmetricsMetricsSpecProvider(context: TutorialContext): Tutori name: i18n.translate('home.tutorials.openmetricsMetrics.nameTitle', { defaultMessage: 'OpenMetrics metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.openmetricsMetrics.shortDescription', { defaultMessage: 'Fetch metrics from an endpoint that serves metrics in OpenMetrics format.', diff --git a/src/plugins/home/server/tutorials/oracle_metrics/index.ts b/src/plugins/home/server/tutorials/oracle_metrics/index.ts index 3144b0a21aab5f..d2ddd19b930a22 100644 --- a/src/plugins/home/server/tutorials/oracle_metrics/index.ts +++ b/src/plugins/home/server/tutorials/oracle_metrics/index.ts @@ -36,6 +36,7 @@ export function oracleMetricsSpecProvider(context: TutorialContext): TutorialSch name: i18n.translate('home.tutorials.oracleMetrics.nameTitle', { defaultMessage: 'oracle metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.oracleMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/osquery_logs/index.ts b/src/plugins/home/server/tutorials/osquery_logs/index.ts index 8781d6201a7710..c4869a889a085a 100644 --- a/src/plugins/home/server/tutorials/osquery_logs/index.ts +++ b/src/plugins/home/server/tutorials/osquery_logs/index.ts @@ -37,6 +37,7 @@ export function osqueryLogsSpecProvider(context: TutorialContext): TutorialSchem name: i18n.translate('home.tutorials.osqueryLogs.nameTitle', { defaultMessage: 'Osquery logs', }), + moduleName, category: TutorialsCategory.SECURITY_SOLUTION, shortDescription: i18n.translate('home.tutorials.osqueryLogs.shortDescription', { defaultMessage: 'Collect the result logs created by osqueryd.', diff --git a/src/plugins/home/server/tutorials/php_fpm_metrics/index.ts b/src/plugins/home/server/tutorials/php_fpm_metrics/index.ts index 975b549c9520b3..470cfed2176fd0 100644 --- a/src/plugins/home/server/tutorials/php_fpm_metrics/index.ts +++ b/src/plugins/home/server/tutorials/php_fpm_metrics/index.ts @@ -36,6 +36,7 @@ export function phpfpmMetricsSpecProvider(context: TutorialContext): TutorialSch name: i18n.translate('home.tutorials.phpFpmMetrics.nameTitle', { defaultMessage: 'PHP-FPM metrics', }), + moduleName, category: TutorialsCategory.METRICS, isBeta: false, shortDescription: i18n.translate('home.tutorials.phpFpmMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/postgresql_logs/index.ts b/src/plugins/home/server/tutorials/postgresql_logs/index.ts index 0c280619858196..e158dedcb03e04 100644 --- a/src/plugins/home/server/tutorials/postgresql_logs/index.ts +++ b/src/plugins/home/server/tutorials/postgresql_logs/index.ts @@ -37,6 +37,7 @@ export function postgresqlLogsSpecProvider(context: TutorialContext): TutorialSc name: i18n.translate('home.tutorials.postgresqlLogs.nameTitle', { defaultMessage: 'PostgreSQL logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.postgresqlLogs.shortDescription', { defaultMessage: 'Collect and parse error and slow logs created by PostgreSQL.', diff --git a/src/plugins/home/server/tutorials/postgresql_metrics/index.ts b/src/plugins/home/server/tutorials/postgresql_metrics/index.ts index f9bb9d249e755d..1add49c10c2a75 100644 --- a/src/plugins/home/server/tutorials/postgresql_metrics/index.ts +++ b/src/plugins/home/server/tutorials/postgresql_metrics/index.ts @@ -36,6 +36,7 @@ export function postgresqlMetricsSpecProvider(context: TutorialContext): Tutoria name: i18n.translate('home.tutorials.postgresqlMetrics.nameTitle', { defaultMessage: 'PostgreSQL metrics', }), + moduleName, category: TutorialsCategory.METRICS, isBeta: false, shortDescription: i18n.translate('home.tutorials.postgresqlMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/prometheus_metrics/index.ts b/src/plugins/home/server/tutorials/prometheus_metrics/index.ts index 06e8a138049d53..900c5da7cdbe34 100644 --- a/src/plugins/home/server/tutorials/prometheus_metrics/index.ts +++ b/src/plugins/home/server/tutorials/prometheus_metrics/index.ts @@ -36,6 +36,7 @@ export function prometheusMetricsSpecProvider(context: TutorialContext): Tutoria name: i18n.translate('home.tutorials.prometheusMetrics.nameTitle', { defaultMessage: 'Prometheus metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.prometheusMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/rabbitmq_metrics/index.ts b/src/plugins/home/server/tutorials/rabbitmq_metrics/index.ts index a646068e4ff341..df0aa57d9feac9 100644 --- a/src/plugins/home/server/tutorials/rabbitmq_metrics/index.ts +++ b/src/plugins/home/server/tutorials/rabbitmq_metrics/index.ts @@ -36,6 +36,7 @@ export function rabbitmqMetricsSpecProvider(context: TutorialContext): TutorialS name: i18n.translate('home.tutorials.rabbitmqMetrics.nameTitle', { defaultMessage: 'RabbitMQ metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.rabbitmqMetrics.shortDescription', { defaultMessage: 'Fetch internal metrics from the RabbitMQ server.', diff --git a/src/plugins/home/server/tutorials/redis_logs/index.ts b/src/plugins/home/server/tutorials/redis_logs/index.ts index e017fae0499a3a..785118b9e5d098 100644 --- a/src/plugins/home/server/tutorials/redis_logs/index.ts +++ b/src/plugins/home/server/tutorials/redis_logs/index.ts @@ -37,6 +37,7 @@ export function redisLogsSpecProvider(context: TutorialContext): TutorialSchema name: i18n.translate('home.tutorials.redisLogs.nameTitle', { defaultMessage: 'Redis logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.redisLogs.shortDescription', { defaultMessage: 'Collect and parse error and slow logs created by Redis.', diff --git a/src/plugins/home/server/tutorials/redis_metrics/index.ts b/src/plugins/home/server/tutorials/redis_metrics/index.ts index bcc4d9bb0b67b9..11d05029844b20 100644 --- a/src/plugins/home/server/tutorials/redis_metrics/index.ts +++ b/src/plugins/home/server/tutorials/redis_metrics/index.ts @@ -36,6 +36,7 @@ export function redisMetricsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.redisMetrics.nameTitle', { defaultMessage: 'Redis metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.redisMetrics.shortDescription', { defaultMessage: 'Fetch internal metrics from Redis.', diff --git a/src/plugins/home/server/tutorials/redisenterprise_metrics/index.ts b/src/plugins/home/server/tutorials/redisenterprise_metrics/index.ts index ffbb5ab75da879..0bc7769f950ede 100644 --- a/src/plugins/home/server/tutorials/redisenterprise_metrics/index.ts +++ b/src/plugins/home/server/tutorials/redisenterprise_metrics/index.ts @@ -36,6 +36,7 @@ export function redisenterpriseMetricsSpecProvider(context: TutorialContext): Tu name: i18n.translate('home.tutorials.redisenterpriseMetrics.nameTitle', { defaultMessage: 'Redis Enterprise metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.redisenterpriseMetrics.shortDescription', { defaultMessage: 'Fetch monitoring metrics from Redis Enterprise Server.', diff --git a/src/plugins/home/server/tutorials/stan_metrics/index.ts b/src/plugins/home/server/tutorials/stan_metrics/index.ts index 616bc7450249e4..b1ad3e9c1404ac 100644 --- a/src/plugins/home/server/tutorials/stan_metrics/index.ts +++ b/src/plugins/home/server/tutorials/stan_metrics/index.ts @@ -36,6 +36,7 @@ export function stanMetricsSpecProvider(context: TutorialContext): TutorialSchem name: i18n.translate('home.tutorials.stanMetrics.nameTitle', { defaultMessage: 'STAN metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.stanMetrics.shortDescription', { defaultMessage: 'Fetch monitoring metrics from the STAN server.', diff --git a/src/plugins/home/server/tutorials/statsd_metrics/index.ts b/src/plugins/home/server/tutorials/statsd_metrics/index.ts index 1dc297e78c791f..9e9d7d6fd3e236 100644 --- a/src/plugins/home/server/tutorials/statsd_metrics/index.ts +++ b/src/plugins/home/server/tutorials/statsd_metrics/index.ts @@ -33,6 +33,7 @@ export function statsdMetricsSpecProvider(context: TutorialContext): TutorialSch name: i18n.translate('home.tutorials.statsdMetrics.nameTitle', { defaultMessage: 'Statsd metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.statsdMetrics.shortDescription', { defaultMessage: 'Fetch monitoring metrics from statsd.', diff --git a/src/plugins/home/server/tutorials/suricata_logs/index.ts b/src/plugins/home/server/tutorials/suricata_logs/index.ts index 6bcfc1d43a2502..eec81b94966479 100644 --- a/src/plugins/home/server/tutorials/suricata_logs/index.ts +++ b/src/plugins/home/server/tutorials/suricata_logs/index.ts @@ -37,6 +37,7 @@ export function suricataLogsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.suricataLogs.nameTitle', { defaultMessage: 'Suricata logs', }), + moduleName, category: TutorialsCategory.SECURITY_SOLUTION, shortDescription: i18n.translate('home.tutorials.suricataLogs.shortDescription', { defaultMessage: 'Collect the result logs created by Suricata IDS/IPS/NSM.', diff --git a/src/plugins/home/server/tutorials/system_logs/index.ts b/src/plugins/home/server/tutorials/system_logs/index.ts index 9bad70699a6ed8..f39df25461a5fe 100644 --- a/src/plugins/home/server/tutorials/system_logs/index.ts +++ b/src/plugins/home/server/tutorials/system_logs/index.ts @@ -37,6 +37,7 @@ export function systemLogsSpecProvider(context: TutorialContext): TutorialSchema name: i18n.translate('home.tutorials.systemLogs.nameTitle', { defaultMessage: 'System logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.systemLogs.shortDescription', { defaultMessage: 'Collect and parse logs written by the local Syslog server.', diff --git a/src/plugins/home/server/tutorials/system_metrics/index.ts b/src/plugins/home/server/tutorials/system_metrics/index.ts index ef1a84ecdbf10b..6bdaaa34a9b2cc 100644 --- a/src/plugins/home/server/tutorials/system_metrics/index.ts +++ b/src/plugins/home/server/tutorials/system_metrics/index.ts @@ -36,6 +36,7 @@ export function systemMetricsSpecProvider(context: TutorialContext): TutorialSch name: i18n.translate('home.tutorials.systemMetrics.nameTitle', { defaultMessage: 'System metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.systemMetrics.shortDescription', { defaultMessage: 'Collect CPU, memory, network, and disk statistics from the host.', diff --git a/src/plugins/home/server/tutorials/traefik_logs/index.ts b/src/plugins/home/server/tutorials/traefik_logs/index.ts index 1876edd6c0bf74..0a84dcb0818835 100644 --- a/src/plugins/home/server/tutorials/traefik_logs/index.ts +++ b/src/plugins/home/server/tutorials/traefik_logs/index.ts @@ -37,6 +37,7 @@ export function traefikLogsSpecProvider(context: TutorialContext): TutorialSchem name: i18n.translate('home.tutorials.traefikLogs.nameTitle', { defaultMessage: 'Traefik logs', }), + moduleName, category: TutorialsCategory.LOGGING, shortDescription: i18n.translate('home.tutorials.traefikLogs.shortDescription', { defaultMessage: 'Collect and parse access logs created by the Traefik Proxy.', diff --git a/src/plugins/home/server/tutorials/traefik_metrics/index.ts b/src/plugins/home/server/tutorials/traefik_metrics/index.ts index a97ee3ab9758a5..4048719239a10c 100644 --- a/src/plugins/home/server/tutorials/traefik_metrics/index.ts +++ b/src/plugins/home/server/tutorials/traefik_metrics/index.ts @@ -33,6 +33,7 @@ export function traefikMetricsSpecProvider(context: TutorialContext): TutorialSc name: i18n.translate('home.tutorials.traefikMetrics.nameTitle', { defaultMessage: 'Traefik metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.traefikMetrics.shortDescription', { defaultMessage: 'Fetch monitoring metrics from Traefik.', diff --git a/src/plugins/home/server/tutorials/uptime_monitors/index.ts b/src/plugins/home/server/tutorials/uptime_monitors/index.ts index fa854a1c235053..7366583e597781 100644 --- a/src/plugins/home/server/tutorials/uptime_monitors/index.ts +++ b/src/plugins/home/server/tutorials/uptime_monitors/index.ts @@ -30,11 +30,13 @@ import { } from '../../services/tutorials/lib/tutorials_registry_types'; export function uptimeMonitorsSpecProvider(context: TutorialContext): TutorialSchema { + const moduleName = 'uptime'; return { id: 'uptimeMonitors', name: i18n.translate('home.tutorials.uptimeMonitors.nameTitle', { defaultMessage: 'Uptime Monitors', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.uptimeMonitors.shortDescription', { defaultMessage: 'Monitor services for their availability', diff --git a/src/plugins/home/server/tutorials/uwsgi_metrics/index.ts b/src/plugins/home/server/tutorials/uwsgi_metrics/index.ts index bbe4ea78ee87c1..f6398be3550fd3 100644 --- a/src/plugins/home/server/tutorials/uwsgi_metrics/index.ts +++ b/src/plugins/home/server/tutorials/uwsgi_metrics/index.ts @@ -36,6 +36,7 @@ export function uwsgiMetricsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.uwsgiMetrics.nameTitle', { defaultMessage: 'uWSGI metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.uwsgiMetrics.shortDescription', { defaultMessage: 'Fetch internal metrics from the uWSGI server.', diff --git a/src/plugins/home/server/tutorials/vsphere_metrics/index.ts b/src/plugins/home/server/tutorials/vsphere_metrics/index.ts index 4450ab30407505..5e1191ffdf8ce5 100644 --- a/src/plugins/home/server/tutorials/vsphere_metrics/index.ts +++ b/src/plugins/home/server/tutorials/vsphere_metrics/index.ts @@ -36,6 +36,7 @@ export function vSphereMetricsSpecProvider(context: TutorialContext): TutorialSc name: i18n.translate('home.tutorials.vsphereMetrics.nameTitle', { defaultMessage: 'vSphere metrics', }), + moduleName, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.vsphereMetrics.shortDescription', { defaultMessage: 'Fetch internal metrics from vSphere.', diff --git a/src/plugins/home/server/tutorials/windows_event_logs/index.ts b/src/plugins/home/server/tutorials/windows_event_logs/index.ts index c2ea9ff3015e43..80f7a58ae14be4 100644 --- a/src/plugins/home/server/tutorials/windows_event_logs/index.ts +++ b/src/plugins/home/server/tutorials/windows_event_logs/index.ts @@ -30,11 +30,13 @@ import { } from '../../services/tutorials/lib/tutorials_registry_types'; export function windowsEventLogsSpecProvider(context: TutorialContext): TutorialSchema { + const moduleName = 'windows'; return { id: 'windowsEventLogs', name: i18n.translate('home.tutorials.windowsEventLogs.nameTitle', { defaultMessage: 'Windows Event Log', }), + moduleName, isBeta: false, category: TutorialsCategory.SECURITY_SOLUTION, shortDescription: i18n.translate('home.tutorials.windowsEventLogs.shortDescription', { diff --git a/src/plugins/home/server/tutorials/windows_metrics/index.ts b/src/plugins/home/server/tutorials/windows_metrics/index.ts index 5333a7b1badf6b..18cdcdc985e544 100644 --- a/src/plugins/home/server/tutorials/windows_metrics/index.ts +++ b/src/plugins/home/server/tutorials/windows_metrics/index.ts @@ -36,6 +36,7 @@ export function windowsMetricsSpecProvider(context: TutorialContext): TutorialSc name: i18n.translate('home.tutorials.windowsMetrics.nameTitle', { defaultMessage: 'Windows metrics', }), + moduleName, isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.windowsMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/zeek_logs/index.ts b/src/plugins/home/server/tutorials/zeek_logs/index.ts index c273a93b1b0d50..e39dcd3409490b 100644 --- a/src/plugins/home/server/tutorials/zeek_logs/index.ts +++ b/src/plugins/home/server/tutorials/zeek_logs/index.ts @@ -37,6 +37,7 @@ export function zeekLogsSpecProvider(context: TutorialContext): TutorialSchema { name: i18n.translate('home.tutorials.zeekLogs.nameTitle', { defaultMessage: 'Zeek logs', }), + moduleName, category: TutorialsCategory.SECURITY_SOLUTION, shortDescription: i18n.translate('home.tutorials.zeekLogs.shortDescription', { defaultMessage: 'Collect the logs created by Zeek/Bro.', diff --git a/src/plugins/home/server/tutorials/zookeeper_metrics/index.ts b/src/plugins/home/server/tutorials/zookeeper_metrics/index.ts index ae146d192432bc..a39540b7399e58 100644 --- a/src/plugins/home/server/tutorials/zookeeper_metrics/index.ts +++ b/src/plugins/home/server/tutorials/zookeeper_metrics/index.ts @@ -36,6 +36,7 @@ export function zookeeperMetricsSpecProvider(context: TutorialContext): Tutorial name: i18n.translate('home.tutorials.zookeeperMetrics.nameTitle', { defaultMessage: 'Zookeeper metrics', }), + moduleName, euiIconType: '/plugins/home/assets/logos/zookeeper.svg', isBeta: false, category: TutorialsCategory.METRICS, diff --git a/x-pack/plugins/ingest_manager/common/types/models/settings.ts b/x-pack/plugins/ingest_manager/common/types/models/settings.ts index 2921808230b47f..98d99911f1b3fc 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/settings.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/settings.ts @@ -10,6 +10,7 @@ interface BaseSettings { package_auto_upgrade?: boolean; kibana_url?: string; kibana_ca_sha256?: string; + has_seen_add_data_notice?: boolean; } export interface Settings extends BaseSettings { diff --git a/x-pack/plugins/ingest_manager/kibana.json b/x-pack/plugins/ingest_manager/kibana.json index 181b93a9e24252..877184740166f5 100644 --- a/x-pack/plugins/ingest_manager/kibana.json +++ b/x-pack/plugins/ingest_manager/kibana.json @@ -5,6 +5,6 @@ "ui": true, "configPath": ["xpack", "ingestManager"], "requiredPlugins": ["licensing", "data", "encryptedSavedObjects"], - "optionalPlugins": ["security", "features", "cloud", "usageCollection"], + "optionalPlugins": ["security", "features", "cloud", "usageCollection", "home"], "extraPublicDirs": ["common"] } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/home_integration/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/home_integration/index.ts new file mode 100644 index 00000000000000..bab6049198249b --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/home_integration/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 { TutorialDirectoryNotice, TutorialDirectoryHeaderLink } from './tutorial_directory_notice'; +export { TutorialModuleNotice } from './tutorial_module_notice'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/home_integration/tutorial_directory_notice.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/home_integration/tutorial_directory_notice.tsx new file mode 100644 index 00000000000000..553623380dcc05 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/home_integration/tutorial_directory_notice.tsx @@ -0,0 +1,154 @@ +/* + * 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, { memo, useState, useCallback, useEffect } from 'react'; +import { BehaviorSubject } from 'rxjs'; +import styled from 'styled-components'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiButtonEmpty, + EuiLink, + EuiCallOut, + EuiSpacer, +} from '@elastic/eui'; +import { + TutorialDirectoryNoticeComponent, + TutorialDirectoryHeaderLinkComponent, +} from 'src/plugins/home/public'; +import { sendPutSettings, useGetSettings, useLink, useCapabilities } from '../../hooks'; + +const FlexItemButtonWrapper = styled(EuiFlexItem)` + &&& { + margin-bottom: 0; + } +`; + +const tutorialDirectoryNoticeState$ = new BehaviorSubject({ + settingsDataLoaded: false, + hasSeenNotice: false, +}); + +export const TutorialDirectoryNotice: TutorialDirectoryNoticeComponent = memo(() => { + const { getHref } = useLink(); + const { show: hasIngestManager } = useCapabilities(); + const { data: settingsData, isLoading } = useGetSettings(); + const [dismissedNotice, setDismissedNotice] = useState(false); + + const dismissNotice = useCallback(async () => { + setDismissedNotice(true); + await sendPutSettings({ + has_seen_add_data_notice: true, + }); + }, []); + + useEffect(() => { + tutorialDirectoryNoticeState$.next({ + settingsDataLoaded: !isLoading, + hasSeenNotice: Boolean(dismissedNotice || settingsData?.item?.has_seen_add_data_notice), + }); + }, [isLoading, settingsData, dismissedNotice]); + + const hasSeenNotice = + isLoading || settingsData?.item?.has_seen_add_data_notice || dismissedNotice; + + return hasIngestManager && !hasSeenNotice ? ( + <> + + + + + ), + }} + /> + } + > +

+ + + + ), + }} + /> +

+ + +
+ + + +
+
+ +
+ { + dismissNotice(); + }} + > + + +
+
+
+
+ + ) : null; +}); + +export const TutorialDirectoryHeaderLink: TutorialDirectoryHeaderLinkComponent = memo(() => { + const { getHref } = useLink(); + const { show: hasIngestManager } = useCapabilities(); + const [noticeState, setNoticeState] = useState({ + settingsDataLoaded: false, + hasSeenNotice: false, + }); + + useEffect(() => { + const subscription = tutorialDirectoryNoticeState$.subscribe((value) => setNoticeState(value)); + return () => { + subscription.unsubscribe(); + }; + }, []); + + return hasIngestManager && noticeState.settingsDataLoaded && noticeState.hasSeenNotice ? ( + + + + ) : null; +}); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/home_integration/tutorial_module_notice.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/home_integration/tutorial_module_notice.tsx new file mode 100644 index 00000000000000..a26691bdd64a00 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/home_integration/tutorial_module_notice.tsx @@ -0,0 +1,74 @@ +/* + * 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, { memo } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiText, EuiLink, EuiSpacer } from '@elastic/eui'; +import { TutorialModuleNoticeComponent } from 'src/plugins/home/public'; +import { useGetPackages, useLink, useCapabilities } from '../../hooks'; + +export const TutorialModuleNotice: TutorialModuleNoticeComponent = memo(({ moduleName }) => { + const { getHref } = useLink(); + const { show: hasIngestManager } = useCapabilities(); + const { data: packagesData, isLoading } = useGetPackages(); + + const pkgInfo = + !isLoading && + packagesData?.response && + packagesData.response.find((pkg) => pkg.name === moduleName); + + if (hasIngestManager && pkgInfo) { + return ( + <> + + +

+ + + + ), + availableAsIntegrationLink: ( + + + + ), + blogPostLink: ( + + + + ), + }} + /> +

+
+ + ); + } + + return null; +}); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx index 623df428b7dd92..94d3379f35e051 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx @@ -22,7 +22,7 @@ import { PAGE_ROUTING_PATHS } from './constants'; import { DefaultLayout, WithoutHeaderLayout } from './layouts'; import { Loading, Error } from './components'; import { IngestManagerOverview, EPMApp, AgentConfigApp, FleetApp, DataStreamApp } from './sections'; -import { DepsContext, ConfigContext, setHttpClient, useConfig } from './hooks'; +import { DepsContext, ConfigContext, useConfig } from './hooks'; import { PackageInstallProvider } from './sections/epm/hooks'; import { useCore, sendSetup, sendGetPermissionsCheck } from './hooks'; import { FleetStatusProvider } from './hooks/use_fleet_status'; @@ -260,7 +260,6 @@ export function renderApp( startDeps: IngestManagerStartDeps, config: IngestManagerConfigType ) { - setHttpClient(coreStart.http); ReactDOM.render( Date: Thu, 9 Jul 2020 13:10:31 -0700 Subject: [PATCH 19/49] New Enterprise Search Kibana plugin (#66922) * Initial App Search in Kibana plugin work - Initializes a new platform plugin that ships out of the box w/ x-pack - Contains a very basic front-end that shows AS engines, error states, or a Setup Guide - Contains a very basic server that remotely calls the AS internal engines API and returns results * Update URL casing to match Kibana best practices - URL casing appears to be snake_casing, but kibana.json casing appears to be camelCase * Register App Search plugin in Home Feature Catalogue * Add custom App Search in Kibana logo - I haven't had much success in surfacing a SVG file via a server-side endpoint/URL, but then I realized EuiIcon supports passing in a ReactElement directly. Woo! * Fix appSearch.host config setting to be optional - instead of crashing folks on load * Rename plugin to Enterprise Search - per product decision, URL should be enterprise_search/app_search and Workplace Search should also eventually live here - reorganize folder structure in anticipation for another workplace_search plugin/codebase living alongside app_search - rename app.tsx/main.tsx to a standard top-level index.tsx (which will contain top-level routes/state) - rename AS->ES files/vars where applicable - TODO: React Router * Set up React Router URL structure * Convert showSetupGuide action/flag to a React Router link - remove showSetupGuide flag - add a new shared helper component for combining EuiButton/EuiLink with React Router behavior (https://github.com/elastic/eui/blob/master/wiki/react-router.md#react-router-51) * Implement Kibana Chrome breadcrumbs - create shared helper (WS will presumably also want this) for generating EUI breadcrumb objects with React Router links+click behavior - create React component that calls chrome.setBreadcrumbs on page mount - clean up type definitions - move app-wide props to IAppSearchProps and update most pages/views to simply import it instead of calling their own definitions * Added server unit tests (#2) * Added unit test for server * PR Feedback * Refactor top-level Kibana props to a global context state - rather them passing them around verbosely as props, the components that need them should be able to call the useContext hook + Remove IAppSearchProps in favor of IKibanaContext + Also rename `appSearchUrl` to `enterpriseSearchUrl`, since this context will contained shared/Kibana-wide values/actions useful to both AS and WS * Added unit tests for public (#4) * application.test.ts * Added Unit Test for EngineOverviewHeader * Added Unit Test for generate_breadcrumbs * Added Unit Test for set_breadcrumb.tsx * Added a unit test for link_events - Also changed link_events.tsx to link_events.ts since it's just TS, no React - Modified letBrowserHandleEvent so it will still return a false boolean when target is blank * Betterize these tests Co-Authored-By: Constance Co-authored-by: Constance * Add UI telemetry tracking to AS in Kibana (#5) * Set up Telemetry usageCollection, savedObjects, route, & shared helper - The Kibana UsageCollection plugin handles collecting our telemetry UI data (views, clicks, errors, etc.) and pushing it to elastic's telemetry servers - That data is stored in incremented in Kibana's savedObjects lib/plugin (as well as mapped) - When an end-user hits a certain view or action, the shared helper will ping the app search telemetry route which increments the savedObject store * Update client-side views/links to new shared telemetry helper * Write tests for new telemetry files * Implement remaining unit tests (#7) * Write tests for React Router+EUI helper components * Update generate_breadcrumbs test - add test suite for generateBreadcrumb() itself (in order to cover a missing branch) - minor lint fixes - remove unnecessary import from set_breadcrumbs test * Write test for get_username util + update test to return a more consistent falsey value (null) * Add test for SetupGuide * [Refactor] Pull out various Kibana context mocks into separate files - I'm creating a reusable useContext mock for shallow()ed enzyme components + add more documentation comments + examples * Write tests for empty state components + test new usecontext shallow mock * Empty state components: Add extra getUserName branch test * Write test for app search index/routes * Write tests for engine overview table + fix bonus bug * Write Engine Overview tests + Update EngineOverview logic to account for issues found during tests :) - Move http to async/await syntax instead of promise syntax (works better with existing HttpServiceMock jest.fn()s) - hasValidData wasn't strict enough in type checking/object nest checking and was causing the app itself to crash (no bueno) * Refactor EngineOverviewHeader test to use shallow + to full coverage - missed adding this test during telemetry work - switching to shallow and beforeAll reduces the test time from 5s to 4s! * [Refactor] Pull out React Router history mocks into a test util helper + minor refactors/updates * Add small tests to increase branch coverage - mostly testing fallbacks or removing fallbacks in favor of strict type interface - these are slightly obsessive so I'd also be fine ditching them if they aren't terribly valuable * Address larger tech debt/TODOs (#8) * Fix optional chaining TODO - turns out my local Prettier wasn't up to date, completely my bad * Fix constants TODO - adds a common folder/architecture for others to use in the future * Remove TODO for eslint-disable-line and specify lint rule being skipped - hopefully that's OK for review, I can't think of any other way to sanely do this without re-architecting the entire file or DDoSing our API * Add server-side logging to route dependencies + add basic example of error catching/logging to Telemetry route + [extra] refactor mockResponseFactory name to something slightly easier to read * Move more Engines Overview API logic/logging to server-side - handle data validation in the server-side - wrap server-side API in a try/catch to account for fetch issues - more correctly return 2xx/4xx statuses and more correctly deal with those responses in the front-end - Add server info/error/debug logs (addresses TODO) - Update tests + minor refactors/cleanup - remove expectResponseToBe200With helper (since we're now returning multiple response types) and instead make mockResponse var name more readable - one-line header auth - update tests with example error logs - update schema validation for `type` to be an enum of `indexed`/`meta` (more accurately reflecting API) * Per telemetry team feedback, rename usageCollection telemetry mapping name to simpler 'app_search' - since their mapping already nests under 'kibana.plugins' - note: I left the savedObjects name with the '_telemetry' suffix, as there very well may be a use case for top-level generic 'app_search' saved objects * Update Setup Guide installation instructions (#9) Co-authored-by: Chris Cressman * [Refactor] DRY out route test helper * [Refactor] Rename public/test_utils to public/__mocks__ - to better follow/use jest setups and for .mock.ts suffixes * Add platinum licensing check to Meta Engines table/call (#11) * Licensing plugin setup * Add LicensingContext setup * Update EngineOverview to not hit meta engines API on platinum license * Add Jest test helpers for future shallow/context use * Update plugin to use new Kibana nav + URL update (#12) * Update new nav categories to add Enterprise Search + update plugin to use new category - per @johnbarrierwilson and Matt Riley, Enterprise Search should be under Kibana and above Observability - Run `node scripts/check_published_api_changes.js --accept` since this new category affects public API * [URL UPDATE] Change '/app/enterprise_search/app_search' to '/app/app_search' - This needs to be done because App Search and Workplace search *have* to be registered as separate plugins to have 2 distinct nav links - Currently Kibana doesn't support nested app names (see: https://github.com/elastic/kibana/issues/59190) but potentially will in the future - To support this change, we need to update applications/index.tsx to NOT handle '/app/enterprise_search' level routing, but instead accept an async imported app component (e.g. AppSearch, WorkplaceSearch). - AppSearch should now treat its router as root '/' instead of '/app_search' - (Addl) Per Josh Dover's recommendation, switch to `` from `` since they're deprecating appBasePath * Update breadcrumbs helper to account for new URLs - Remove path for Enterprise Search breadcrumb, since '/app/enterprise_search' will not link anywhere meaningful for the foreseeable future, so the Enterprise Search root should not go anywhere - Update App Search helper to go to root path, per new React Router setup Test changes: - Mock custom basepath for App Search tests - Swap enterpriseSearchBreadcrumbs and appSearchBreadcrumbs test order (since the latter overrides the default mock) * Add create_first_engine_button telemetry tracking to EmptyState * Switch plugin URLs back to /app/enterprise_search/app_search Now that https://github.com/elastic/kibana/pull/66455 has been merged in :tada: * Add i18n formatted messages / translations (#13) * Add i18n provider and formatted/i18n translated messages * Update tests to account for new I18nProvider context + FormattedMessage components - Add new mountWithContext helper that provides all contexts+providers used in top-level app - Add new shallowWithIntl helper for shallow() components that dive into FormattedMessage * Format i18n dates and numbers + update some mock tests to not throw react-intl invalid date messages * Update EngineOverviewHeader to disable button on prop * Address review feedback (#14) * Fix Prettier linting issues * Escape App Search API endpoint URLs - per PR feedback - querystring should automatically encodeURIComponent / escape query param strings * Update server plugin.ts to use getStartServices() rather than storing local references from start() - Per feedback: https://github.com/elastic/kibana/blob/master/src/core/CONVENTIONS.md#applications - Note: savedObjects.registerType needs to be outside of getStartServices, or an error is thrown - Side update to registerTelemetryUsageCollector to simplify args - Update/fix tests to account for changes * E2E testing (#6) * Wired up basics for E2E testing * Added version with App Search * Updated naming * Switched configuration around * Added concept of 'fixtures' * Figured out how to log in as the enterprise_search user * Refactored to use an App Search service * Added some real tests * Added a README * Cleanup * More cleanup * Error handling + README updatre * Removed unnecessary files * Apply suggestions from code review Co-authored-by: Constance * Update x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx Co-authored-by: Constance * PR feedback - updated README * Additional lint fixes Co-authored-by: Constance * Add README and CODEOWNERS (#15) * Add plugin README and CODEOWNERS * Fix Typescript errors (#16) * Fix public mocks * Fix empty states types * Fix engine table component errors * Fix engine overview component errors * Fix setup guide component errors - SetBreadcrumbs will be fixed in a separate commit * Fix App Search index errors * Fix engine overview header component errors * Fix applications context index errors * Fix kibana breadcrumb helper errors * Fix license helper errors * :exclamation: Refactor React Router EUI link/button helpers - in order to fix typescript errors - this changes the component logic significantly to a react render prop, so that the Link and Button components can have different types - however, end behavior should still remain the same * Fix telemetry helper errors * Minor unused var cleanup in plugin files * Fix telemetry collector/savedobjects errors * Fix MockRouter type errors and add IRouteDependencies export - routes will use IRouteDependencies in the next few commits * Fix engines route errors * Fix telemetry route errors * Remove any type from source code - thanks to Scotty for the inspiration * Add eslint rules for Enterprise Search plugin - Add checks for type any, but only on non-test files - Disable react-hooks/exhaustive-deps, since we're already disabling it in a few files and other plugins also have it turned off * Cover uncovered lines in engines_table and telemetry tests * Fixed TS warnings in E2E tests (#17) * Feedback: Convert static CSS values to EUI variables where possible * Feedback: Flatten nested CSS where possible - Prefer setting CSS class overrides on individual EUI components, not on a top-level page + Change CSS class casing from kebab-case to camelCase to better match EUI/Kibana + Remove unnecessary .euiPageContentHeader margin-bottom override by changing the panelPaddingSize of euiPageContent + Decrease engine overview table padding on mobile * Refactor out components shared with Workplace Search (#18) * Move getUserName helper to shared - in preparation for Workplace Search plugin also using this helper * Move Setup Guide layout to a shared component * Setup Guide: add extra props for standard/native auth links Note: It's possible this commit may be unnecessary if we can publish shared Enterprise Search security mode docs * Update copy per feedback from copy team * Address various telemetry issues - saved objects: removing indexing per #43673 - add schema and generate json per #64942 - move definitions over to collectors since saved objects is mostly empty at this point, and schema throws an error when it imports an obj instead of being defined inline - istanbul ignore saved_objects file since it doesn't have anything meaningful to test but was affecting code coverage * Disable plugin access if a normal user does not have access to App Search (#19) * Set up new server security dependency and configs * Set up access capabilities * Set up checkAccess helper/caller * Remove NoUserState component from the public UI - Since this is now being handled by checkAccess / normal users should never see the plugin at all if they don't have an account/access, the component is no longer needed * Update server routes to account for new changes - Remove login redirect catch from routes, since the access helper should now handle that for most users by disabling the plugin (superusers will see a generic cannot connect/error screen) - Refactor out new config values to a shared mock * Refactor Enterprise Search http call to hit/return new internal API endpoint + pull out the http call to a separate library for upcoming public URL work (so that other files can call it directly as well) * [Discussion] Increase timeout but add another warning timeout for slow servers - per recommendation/convo with Brandon * Register feature control * Remove no_as_account from UI telemetry - since we're no longer tracking that in the UI * Address PR feedback - isSuperUser check * Public URL support for Elastic Cloud (#21) * Add server-side public URL route - Per feedback from Kibana platform team, it's not possible to pass info from server/ to public/ without a HTTP call :[ * Update MockRouter for routes without any payload/params * Add client-side helper for calling the new public URL API + API seems to return a URL a trailing slash, which we need to omit * Update public/plugin.ts to check and set a public URL - relies on this.hasCheckedPublicUrl to only make the call once per page load instead of on every page nav * Fix failing feature control tests - Split up scenario cases as needed - Add plugin as an exception alongside ML & Monitoring * Address PR feedback - version: kibana - copy edits - Sass vars - code cleanup * Casing feedback: change all plugin registration IDs from snake_case to camelCase - note: current remainng snake_case exceptions are telemetry keys - file names and api endpoints are snake_case per conventions * Misc security feedback - remove set - remove unnecessary capabilities registration - telemetry namespace agnostic * Security feedback: add warn logging to telemetry collector see https://github.com/elastic/kibana/pull/66922#discussion_r451215760 - add if statement - pass log dependency around (this is kinda medium, should maybe refactor) - update tests - move test file comment to the right file (was meant for telemetry route file) * Address feedback from Pierre - Remove unnecessary ServerConfigType - Remove unnecessary uiCapabilities - Move registerTelemetryRoute / SavedObjectsServiceStart workaround - Remove unnecessary license optional chaining * PR feedback Address type/typos * Fix telemetry API call returning 415 on Chrome - I can't even?? I swear charset=utf-8 fixed the same error a few weeks ago * Fix failing tests * Update Enterprise Search functional tests (without host) to run on CI - Fix incorrect navigateToApp slug (hadn't realized this was a URL, not an ID) - Update without_host_configured tests to run without API key - Update README * Address PR feedback from Pierre - remove unnecessary authz? - remove unnecessary content-type json headers - add loggingSystemMock.collect(mockLogger).error assertion - reconstrcut new MockRouter on beforeEach for better sandboxing - fix incorrect describe()s -should be it() - pull out reusable mockDependencies helper (renamed/extended from mockConfig) for tests that don't particularly use config/log but still want to pass type definitions - Fix comment copy Co-authored-by: Jason Stoltzfus Co-authored-by: Chris Cressman Co-authored-by: scottybollinger Co-authored-by: Elastic Machine --- .eslintrc.js | 12 + .github/CODEOWNERS | 5 + .../collapsible_nav.test.tsx.snap | 6 +- src/core/public/public.api.md | 6 + src/core/server/server.api.md | 6 + src/core/utils/default_app_categories.ts | 12 +- x-pack/.i18nrc.json | 1 + x-pack/plugins/enterprise_search/README.md | 25 ++ .../enterprise_search/common/constants.ts | 7 + x-pack/plugins/enterprise_search/kibana.json | 10 + .../public/applications/__mocks__/index.ts | 13 + .../__mocks__/kibana_context.mock.ts | 17 ++ .../__mocks__/license_context.mock.ts | 11 + .../__mocks__/mount_with_context.mock.tsx | 49 ++++ .../__mocks__/react_router_history.mock.ts | 25 ++ .../__mocks__/shallow_usecontext.mock.ts | 40 ++++ .../__mocks__/shallow_with_i18n.mock.tsx | 30 +++ .../applications/app_search/assets/engine.svg | 3 + .../app_search/assets/getting_started.png | Bin 0 -> 92044 bytes .../applications/app_search/assets/logo.svg | 4 + .../app_search/assets/meta_engine.svg | 4 + .../components/empty_states/empty_state.tsx | 74 ++++++ .../components/empty_states/empty_states.scss | 19 ++ .../empty_states/empty_states.test.tsx | 53 ++++ .../components/empty_states/error_state.tsx | 95 ++++++++ .../components/empty_states/index.ts | 9 + .../components/empty_states/loading_state.tsx | 30 +++ .../engine_overview/engine_overview.scss | 27 +++ .../engine_overview/engine_overview.test.tsx | 171 +++++++++++++ .../engine_overview/engine_overview.tsx | 155 ++++++++++++ .../engine_overview/engine_table.test.tsx | 80 +++++++ .../engine_overview/engine_table.tsx | 153 ++++++++++++ .../components/engine_overview/index.ts | 7 + .../engine_overview_header.test.tsx | 41 ++++ .../engine_overview_header.tsx | 72 ++++++ .../engine_overview_header/index.ts | 7 + .../components/setup_guide/index.ts | 7 + .../setup_guide/setup_guide.test.tsx | 21 ++ .../components/setup_guide/setup_guide.tsx | 64 +++++ .../applications/app_search/index.test.tsx | 46 ++++ .../public/applications/app_search/index.tsx | 28 +++ .../public/applications/index.test.tsx | 40 ++++ .../public/applications/index.tsx | 56 +++++ .../get_enterprise_search_url.test.ts | 30 +++ .../get_enterprise_search_url.ts | 27 +++ .../shared/enterprise_search_url/index.ts | 7 + .../generate_breadcrumbs.test.ts | 206 ++++++++++++++++ .../generate_breadcrumbs.ts | 54 +++++ .../shared/kibana_breadcrumbs/index.ts | 9 + .../set_breadcrumbs.test.tsx | 63 +++++ .../kibana_breadcrumbs/set_breadcrumbs.tsx | 43 ++++ .../applications/shared/licensing/index.ts | 8 + .../shared/licensing/license_checks.test.ts | 33 +++ .../shared/licensing/license_checks.ts | 11 + .../shared/licensing/license_context.test.tsx | 24 ++ .../shared/licensing/license_context.tsx | 29 +++ .../react_router_helpers/eui_link.test.tsx | 77 ++++++ .../shared/react_router_helpers/eui_link.tsx | 57 +++++ .../shared/react_router_helpers/index.ts | 9 + .../react_router_helpers/link_events.test.ts | 102 ++++++++ .../react_router_helpers/link_events.ts | 31 +++ .../applications/shared/setup_guide/index.ts | 7 + .../shared/setup_guide/setup_guide.scss | 51 ++++ .../shared/setup_guide/setup_guide.test.tsx | 44 ++++ .../shared/setup_guide/setup_guide.tsx | 226 ++++++++++++++++++ .../applications/shared/telemetry/index.ts | 8 + .../shared/telemetry/send_telemetry.test.tsx | 56 +++++ .../shared/telemetry/send_telemetry.tsx | 50 ++++ .../plugins/enterprise_search/public/index.ts | 12 + .../enterprise_search/public/plugin.ts | 88 +++++++ .../collectors/app_search/telemetry.test.ts | 143 +++++++++++ .../server/collectors/app_search/telemetry.ts | 156 ++++++++++++ .../plugins/enterprise_search/server/index.ts | 29 +++ .../server/lib/check_access.test.ts | 128 ++++++++++ .../server/lib/check_access.ts | 76 ++++++ .../lib/enterprise_search_config_api.test.ts | 111 +++++++++ .../lib/enterprise_search_config_api.ts | 78 ++++++ .../enterprise_search/server/plugin.ts | 121 ++++++++++ .../server/routes/__mocks__/index.ts | 8 + .../server/routes/__mocks__/router.mock.ts | 102 ++++++++ .../__mocks__/routerDependencies.mock.ts | 27 +++ .../server/routes/app_search/engines.test.ts | 160 +++++++++++++ .../server/routes/app_search/engines.ts | 59 +++++ .../routes/app_search/telemetry.test.ts | 108 +++++++++ .../server/routes/app_search/telemetry.ts | 50 ++++ .../enterprise_search/public_url.test.ts | 52 ++++ .../routes/enterprise_search/public_url.ts | 26 ++ .../saved_objects/app_search/telemetry.ts | 19 ++ .../privileges/privileges.test.ts | 14 +- .../authorization/privileges/privileges.ts | 1 + .../schema/xpack_plugins.json | 34 +++ x-pack/scripts/functional_tests.js | 1 + .../apis/features/features/features.ts | 1 + .../functional_enterprise_search/README.md | 41 ++++ .../app_search/engines.ts | 75 ++++++ .../with_host_configured/index.ts | 13 + .../app_search/setup_guide.ts | 36 +++ .../without_host_configured/index.ts | 15 ++ .../base_config.ts | 20 ++ .../ftr_provider_context.d.ts | 12 + .../page_objects/app_search.ts | 30 +++ .../page_objects/index.ts | 13 + .../services/app_search_client.ts | 121 ++++++++++ .../services/app_search_service.ts | 77 ++++++ .../services/index.ts | 13 + .../with_host_configured.config.ts | 31 +++ .../without_host_configured.config.ts | 23 ++ .../common/nav_links_builder.ts | 4 + .../security_and_spaces/tests/catalogue.ts | 16 +- .../security_and_spaces/tests/nav_links.ts | 12 +- .../security_only/tests/catalogue.ts | 16 +- .../security_only/tests/nav_links.ts | 10 +- 112 files changed, 4968 insertions(+), 17 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/README.md create mode 100644 x-pack/plugins/enterprise_search/common/constants.ts create mode 100644 x-pack/plugins/enterprise_search/kibana.json create mode 100644 x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_context.mock.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/__mocks__/license_context.mock.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/assets/engine.svg create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/assets/getting_started.png create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/assets/logo.svg create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/assets/meta_engine.svg create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/index.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/index.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx create mode 100644 x-pack/plugins/enterprise_search/public/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/plugin.ts create mode 100644 x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts create mode 100644 x-pack/plugins/enterprise_search/server/index.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/check_access.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/check_access.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts create mode 100644 x-pack/plugins/enterprise_search/server/plugin.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/__mocks__/routerDependencies.mock.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.ts create mode 100644 x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts create mode 100644 x-pack/test/functional_enterprise_search/README.md create mode 100644 x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts create mode 100644 x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/index.ts create mode 100644 x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts create mode 100644 x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts create mode 100644 x-pack/test/functional_enterprise_search/base_config.ts create mode 100644 x-pack/test/functional_enterprise_search/ftr_provider_context.d.ts create mode 100644 x-pack/test/functional_enterprise_search/page_objects/app_search.ts create mode 100644 x-pack/test/functional_enterprise_search/page_objects/index.ts create mode 100644 x-pack/test/functional_enterprise_search/services/app_search_client.ts create mode 100644 x-pack/test/functional_enterprise_search/services/app_search_service.ts create mode 100644 x-pack/test/functional_enterprise_search/services/index.ts create mode 100644 x-pack/test/functional_enterprise_search/with_host_configured.config.ts create mode 100644 x-pack/test/functional_enterprise_search/without_host_configured.config.ts diff --git a/.eslintrc.js b/.eslintrc.js index 8d979dc0f8645e..4425ad3a12659d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -906,6 +906,18 @@ module.exports = { }, }, + /** + * Enterprise Search overrides + */ + { + files: ['x-pack/plugins/enterprise_search/**/*.{ts,tsx}'], + excludedFiles: ['x-pack/plugins/enterprise_search/**/*.{test,mock}.{ts,tsx}'], + rules: { + 'react-hooks/exhaustive-deps': 'off', + '@typescript-eslint/no-explicit-any': 'error', + }, + }, + /** * disable jsx-a11y for kbn-ui-framework */ diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4aab9943022d4c..f053c6da9c29bd 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -201,6 +201,11 @@ x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @elastic/kib # Design **/*.scss @elastic/kibana-design +# Enterprise Search +/x-pack/plugins/enterprise_search/ @elastic/app-search-frontend @elastic/workplace-search-frontend +/x-pack/test/functional_enterprise_search/ @elastic/app-search-frontend @elastic/workplace-search-frontend +/x-pack/plugins/enterprise_search/**/*.scss @elastic/ent-search-design + # Elasticsearch UI /src/plugins/dev_tools/ @elastic/es-ui /src/plugins/console/ @elastic/es-ui diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index 9fee7b50f371b2..1cfded4dc7b8f4 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -149,7 +149,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "euiIconType": "logoSecurity", "id": "security", "label": "Security", - "order": 3000, + "order": 4000, }, "data-test-subj": "siem", "href": "siem", @@ -164,7 +164,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "euiIconType": "logoObservability", "id": "observability", "label": "Observability", - "order": 2000, + "order": 3000, }, "data-test-subj": "metrics", "href": "metrics", @@ -233,7 +233,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "euiIconType": "logoObservability", "id": "observability", "label": "Observability", - "order": 2000, + "order": 3000, }, "data-test-subj": "logs", "href": "logs", diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 86e281a49b744a..40fc3f977006f4 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -582,6 +582,12 @@ export const DEFAULT_APP_CATEGORIES: Readonly<{ euiIconType: string; order: number; }; + enterpriseSearch: { + id: string; + label: string; + order: number; + euiIconType: string; + }; observability: { id: string; label: string; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index efeafc9e68d359..95912c3af63e51 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -566,6 +566,12 @@ export const DEFAULT_APP_CATEGORIES: Readonly<{ euiIconType: string; order: number; }; + enterpriseSearch: { + id: string; + label: string; + order: number; + euiIconType: string; + }; observability: { id: string; label: string; diff --git a/src/core/utils/default_app_categories.ts b/src/core/utils/default_app_categories.ts index 5708bcfeac31a7..cc9bfb1db04d5e 100644 --- a/src/core/utils/default_app_categories.ts +++ b/src/core/utils/default_app_categories.ts @@ -29,20 +29,28 @@ export const DEFAULT_APP_CATEGORIES = Object.freeze({ euiIconType: 'logoKibana', order: 1000, }, + enterpriseSearch: { + id: 'enterpriseSearch', + label: i18n.translate('core.ui.enterpriseSearchNavList.label', { + defaultMessage: 'Enterprise Search', + }), + order: 2000, + euiIconType: 'logoEnterpriseSearch', + }, observability: { id: 'observability', label: i18n.translate('core.ui.observabilityNavList.label', { defaultMessage: 'Observability', }), euiIconType: 'logoObservability', - order: 2000, + order: 3000, }, security: { id: 'security', label: i18n.translate('core.ui.securityNavList.label', { defaultMessage: 'Security', }), - order: 3000, + order: 4000, euiIconType: 'logoSecurity', }, management: { diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 596ba17d343c0c..d0055008eb9bf7 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -16,6 +16,7 @@ "xpack.data": "plugins/data_enhanced", "xpack.embeddableEnhanced": "plugins/embeddable_enhanced", "xpack.endpoint": "plugins/endpoint", + "xpack.enterpriseSearch": "plugins/enterprise_search", "xpack.features": "plugins/features", "xpack.fileUpload": "plugins/file_upload", "xpack.globalSearch": ["plugins/global_search"], diff --git a/x-pack/plugins/enterprise_search/README.md b/x-pack/plugins/enterprise_search/README.md new file mode 100644 index 00000000000000..8c316c848184b9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/README.md @@ -0,0 +1,25 @@ +# Enterprise Search + +## Overview + +This plugin's goal is to provide a Kibana user interface to the Enterprise Search solution's products (App Search and Workplace Search). In its current MVP state, the plugin provides a basic engines overview from App Search with the goal of gathering user feedback and raising product awareness. + +## Development + +1. When developing locally, Enterprise Search should be running locally alongside Kibana on `localhost:3002`. +2. Update `config/kibana.dev.yml` with `enterpriseSearch.host: 'http://localhost:3002'` +3. For faster QA/development, run Enterprise Search on [elasticsearch-native auth](https://www.elastic.co/guide/en/app-search/current/security-and-users.html#app-search-self-managed-security-and-user-management-elasticsearch-native-realm) and log in as the `elastic` superuser on Kibana. + +## Testing + +### Unit tests + +From `kibana-root-folder/x-pack`, run: + +```bash +yarn test:jest plugins/enterprise_search +``` + +### E2E tests + +See [our functional test runner README](../../test/functional_enterprise_search). diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts new file mode 100644 index 00000000000000..c134131caba75c --- /dev/null +++ b/x-pack/plugins/enterprise_search/common/constants.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 const ENGINES_PAGE_SIZE = 10; diff --git a/x-pack/plugins/enterprise_search/kibana.json b/x-pack/plugins/enterprise_search/kibana.json new file mode 100644 index 00000000000000..9a2daefcd8c6e6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "enterpriseSearch", + "version": "kibana", + "kibanaVersion": "kibana", + "requiredPlugins": ["home", "features", "licensing"], + "configPath": ["enterpriseSearch"], + "optionalPlugins": ["usageCollection", "security"], + "server": true, + "ui": true +} diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts new file mode 100644 index 00000000000000..14fde357a980a0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts @@ -0,0 +1,13 @@ +/* + * 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 { mockHistory } from './react_router_history.mock'; +export { mockKibanaContext } from './kibana_context.mock'; +export { mockLicenseContext } from './license_context.mock'; +export { mountWithContext, mountWithKibanaContext } from './mount_with_context.mock'; +export { shallowWithIntl } from './shallow_with_i18n.mock'; + +// Note: shallow_usecontext must be imported directly as a file diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_context.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_context.mock.ts new file mode 100644 index 00000000000000..fcfa1b0a21f130 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_context.mock.ts @@ -0,0 +1,17 @@ +/* + * 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 { httpServiceMock } from 'src/core/public/mocks'; + +/** + * A set of default Kibana context values to use across component tests. + * @see enterprise_search/public/index.tsx for the KibanaContext definition/import + */ +export const mockKibanaContext = { + http: httpServiceMock.createSetupContract(), + setBreadcrumbs: jest.fn(), + enterpriseSearchUrl: 'http://localhost:3002', +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/license_context.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/license_context.mock.ts new file mode 100644 index 00000000000000..7c37ecc7cde1b8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/license_context.mock.ts @@ -0,0 +1,11 @@ +/* + * 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 { licensingMock } from '../../../../licensing/public/mocks'; + +export const mockLicenseContext = { + license: licensingMock.createLicense(), +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx b/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx new file mode 100644 index 00000000000000..dfcda544459d44 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx @@ -0,0 +1,49 @@ +/* + * 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 { mount } from 'enzyme'; + +import { I18nProvider } from '@kbn/i18n/react'; +import { KibanaContext } from '../'; +import { mockKibanaContext } from './kibana_context.mock'; +import { LicenseContext } from '../shared/licensing'; +import { mockLicenseContext } from './license_context.mock'; + +/** + * This helper mounts a component with all the contexts/providers used + * by the production app, while allowing custom context to be + * passed in via a second arg + * + * Example usage: + * + * const wrapper = mountWithContext(, { enterpriseSearchUrl: 'someOverride', license: {} }); + */ +export const mountWithContext = (children: React.ReactNode, context?: object) => { + return mount( + + + + {children} + + + + ); +}; + +/** + * This helper mounts a component with just the default KibanaContext - + * useful for isolated / helper components that only need this context + * + * Same usage/override functionality as mountWithContext + */ +export const mountWithKibanaContext = (children: React.ReactNode, context?: object) => { + return mount( + + {children} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts new file mode 100644 index 00000000000000..fd422465d87f1b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/** + * NOTE: This variable name MUST start with 'mock*' in order for + * Jest to accept its use within a jest.mock() + */ +export const mockHistory = { + createHref: jest.fn(({ pathname }) => `/enterprise_search${pathname}`), + push: jest.fn(), + location: { + pathname: '/current-path', + }, +}; + +jest.mock('react-router-dom', () => ({ + useHistory: jest.fn(() => mockHistory), +})); + +/** + * For example usage, @see public/applications/shared/react_router_helpers/eui_link.test.tsx + */ diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts new file mode 100644 index 00000000000000..767a52a75d1fbb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * NOTE: These variable names MUST start with 'mock*' in order for + * Jest to accept its use within a jest.mock() + */ +import { mockKibanaContext } from './kibana_context.mock'; +import { mockLicenseContext } from './license_context.mock'; + +jest.mock('react', () => ({ + ...(jest.requireActual('react') as object), + useContext: jest.fn(() => ({ ...mockKibanaContext, ...mockLicenseContext })), +})); + +/** + * Example usage within a component test using shallow(): + * + * import '../../../test_utils/mock_shallow_usecontext'; // Must come before React's import, adjust relative path as needed + * + * import React from 'react'; + * import { shallow } from 'enzyme'; + * + * // ... etc. + */ + +/** + * If you need to override the default mock context values, you can do so via jest.mockImplementation: + * + * import React, { useContext } from 'react'; + * + * // ... etc. + * + * it('some test', () => { + * useContext.mockImplementationOnce(() => ({ enterpriseSearchUrl: 'someOverride' })); + * }); + */ diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.tsx b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.tsx new file mode 100644 index 00000000000000..ae7d0b09f9872e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_with_i18n.mock.tsx @@ -0,0 +1,30 @@ +/* + * 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 { shallow } from 'enzyme'; +import { I18nProvider } from '@kbn/i18n/react'; +import { IntlProvider } from 'react-intl'; + +const intlProvider = new IntlProvider({ locale: 'en', messages: {} }, {}); +const { intl } = intlProvider.getChildContext(); + +/** + * This helper shallow wraps a component with react-intl's which + * fixes "Could not find required `intl` object" console errors when running tests + * + * Example usage (should be the same as shallow()): + * + * const wrapper = shallowWithIntl(); + */ +export const shallowWithIntl = (children: React.ReactNode) => { + const context = { context: { intl } }; + + return shallow({children}, context) + .childAt(0) + .dive(context) + .shallow(); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/assets/engine.svg b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/engine.svg new file mode 100644 index 00000000000000..ceab918e92e702 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/engine.svg @@ -0,0 +1,3 @@ + + + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/assets/getting_started.png b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/getting_started.png new file mode 100644 index 0000000000000000000000000000000000000000..4d988d14f0483c31045783b714eedde9baac92b9 GIT binary patch literal 92044 zcmV(^K-IsAP) zsG9&hd;mRu0^!&IK7arJ{{TCB06cvFId<-NofbTL6ghMG`uzZFv;a7D6+3tUJ9*{q z`uO?#2?+@lIC2jU4*)oG^Y!`_6&3dQ{Pg$x6*_eQJ%9D~_W(9FkqckcB205xs^G;9|;bOAGI)7$0&GG+lVW9#z!@$&QlCr}z18W=ou0WV+y zEnW5Y`2Zg(;<+uh8~ z&jF>Gf}Xztqm}`unjS!L>+SF2a+MlTi2FeipnHxTM-{-FS#_WRg?celiPYBG0-27^(bja7xpa)M0gHeDPo)ww zW=Tv`H(88ehOUmGy+mV^Eo|F9UXUzKg(gaaX~nBWf8a}Kmao6mQdL|5acRTE$3a6& zrNG~Xnz^B)rn$Vr0eo_iwd=*o(XzCybbE$~jIhh`zSi=f!0%&~<2pP<08pk+ zhT#E|wwaos7<6%2jpNhf_aCa4Yi)D^wB5|s<9nv)I(Of0o#ta@ZIY9kCs~h-j*_aZ zvC7=`U!u@uy`&Hu>`1DJ&{?czEhACu3Ejw+1%F0Z;+gMz=b|pfu5U{G`OSz z%g1_)rB{Hx+kBnQ$-5SByTH7#PQj@GySrmWF0rnp>U~;5k;njel^jTt08)V8pi0g0yOri|~+w35@m z$eoR^hI5f`S4CkrW>$++fISaEfS;ycW~y#5HAxUHgfajDWKBs#K~#9!?43)JBSjEI zF>}*q?%+pa$t^eq=ip471Y^yTPe#_u@Tm|IV8%k!Om|o1CoyBT7U-0t_ zxZ(G*U(@sIR|sqL;;#l;Q)maztH1UCyn@n7(Zg_-=$(PsX+3^)z^ zj9PPjlV8cBYslpHEx$XzUf_44PW-;=C5m;=FPD7OqF*gnooTLHe&6uxA*-%$SHhA# zZPZf9vSCm|_MEH6?$^uoRcI^tJBioO(&E=WzgKhQ|I0@n2%xXk;$eBrhoW7(9P8Yc zNxr9_)6x?~@Dpw2%x;oMBVMmsqs{zS^vG`t23ZZX;Ah$WSNv-HqQ`Uovi1tUIYB0T ztbC#G3thSaM42!eZ$+HRwbH|_vO51Iar%ni=U^#h8u*PD3Q=9FnDxi09_zc4U+{N# zq=ppy4t_PCPnQ{W{%ZE+Bah!h;1>^jUT66xL9#l(E`I+*eqUjpM*dp3&-vZ>y})$O&pZS){235h zsuExPhW`MRt}K9N{&cyO{Do1a@C)@sb$E%;T3+m88k{idYD$fC$8W*E)D$6=f`1uC zoc+H54lafHnQx!j*w5p>=Mh-iq<;?TJ58Id=LqyP=dbhIMzZ>xSeHn#DS1A_usQR4 z*P25eQd4La4=_n2DssuQJ=r$_t&Kqc7E;51N>jHP7~B2#v(MtHvDx>-DJ0P zi6;DD9B)pI{D7T-l}Pc$FDtiWuru@9R1f?r{_3YqLz>ZXPYwJCo`PQ^znpLN3VxNv z;1_c<6-4Rz3#j{QXb-=3zs0Z3FA{%Nnb_>I0+=gfvjt>H559*c0cJ|M18WM|iSML@VN-1QP zw7S{d^c}xnQFV z(5$L3{31CbTc0j~@g5i2kgAbq&d||{reRlupi&$!GVElaNhBtw6LKc1;}9dv{463R zlsAj+`1vNT;HTffkCT=B%v`}QAVkFX5`Rv|xNd9tO#HOkb5al|KW6W9&64mK5r@)n zrXmceN>-H_MaiE|V!3>t`L+0QUa!(UV5H$h>h*5-ie26tei#3a`&F8z((py3p7M0# zcjGTz=3N#i5i6hiB*^2vCp+>ag-p}!`I;g~lh4KGFd~QM5Pbc`>yk%0-;~Y#SXK4@ z=lnYP{XRdwBVoQSe&ZuC_+diPsPUr@!Oyz3^)-Kr1*t1L6r2fa;#cHdwJff) zEhT=H-};K568yW875Mq#;AdKx5^^lVPsj@P)7}rG_YytPmx)xp9qmZ1AOF^iVuGXOKIoqa=71CeG!FUrb}>H~a>^$->A# zZ`Ye>RdPs)(rtF|a~*m!@tXp6VI=^ol5)eZ`flU#DeeUqAw2FF{5V`B2H=-@yHM<; z8V?GiDcxnD3MeeG!=p}qgh#&Tm*7`L?TcUV>mkcE10_F-iOP%gqKKPy@#|v+@b*w_ zJItcOmRlZ~)K3e}W$3&}|BD~fXt4+DIh*NbJtY@@aT@$?{9z*bv)s4*W`TdruYo@( zRK&G#wm{GqINM(n`LFra`Q^?Zo5Ch^Z7k9=eo>Ax;L$tzQIk!q9krW4pMN1-E(S)` z8N=@p_vuPK0@BX?gTEVa4VFw?rB7%13H*a6fSiIFNb#yt`+Bnwg3%{g|@WCEn zEPYbxrY3b`_65xbKQ-jBqAzcf8N^ae(>}jei^>vA0Y$rh&XwEP<{sm4R=n33G&lSj z_#K&4YvgY}#6CBnEnbSh<}c5wxho9GTrGzqr;A@wkFh=T*SO@@dqP$v6E<}OVaac; zh01fu71zs)UyHwL@pG_c34T1@Bh3(3;rSej*ByT<1(tQ2no@b52I;g(>@tB$0M zX8uyI61!DoLfb`*Hg|rBVEyC=psb#o%D;U1#3McqD?}!8@gZ&5iR-9|M|1Pxu0zaB zj1-1FJDAbn=^baD!&(?y!lYVLr*1N*S?$4bb+m0EeSV!Eq{KyOQC2s8J>%!X6Temi zbZiHBbmcLxB9Y;D@}o((q9R6g>Z01jioiVND+8L32eC!44Q_@^`x$Pl@vG8|+I5nA z1U7E`s%U-VRL327YX4J6G&;ph>bmID6Z9sqym^pha+4Fkz2uiS-fI2f0yu07D9i2n z!vQUNQOc25b{4dOB2X5t1hSTQ&$!CzLrrxsMU3o`f1l&LIdWwqKZ_{+XMCTDzZJeH5g7b}pT;x4lb@R>mIDSqtGn@6X*x*BPh}I3ZmbkYta|GdGPCt5qQrQL zw>ngF8Gag;WmJ?%Up1{|*cfj7gxAS0c1KR%&$s-BUyEhosQBIag*}yCKjTMWlmF*0 zpLm=C=zPSHbae)8xEdTXcCh8Q_LenlKe(xd@GON}!>D^$q98T?m@sb~_E`Czz5GeSyMNsE0yl81jCY#3I%Z91U{AEIF5icR0nH$Z8 z7b(LpBbW!jlE1#guYQA^Ow4y_sH{q_8U7kk_U7kbtPBY;6XIj|RaILQ?UO&Vh~)dq zj^L87(rV*jmqgF`ML_Mk)~Z0Yc`vb~&lbQ_MBE?lnYR(-9z>y>$VtA+odQSpiFG`F zG8WD}c5*Btk}#mr`6KRu-y=VJcO`tCzy2nFCiP`9@^Ryv?)ilUAu8@;3I5n@^5)-! z8`4#*^!&P+K)~c};{O2YG*xVVnYcYrJz|TjZY*B|QN`CCZzp57$F+Lt7{3I>0 z-9=?*X}1KzFY(45k5|jdU)|66TPACa${T)dehGe^{9Z)F6FUW|^8r5DH5~l8C4xJD z@|P}`UjF*!6OVNtAPZ+o#Oc|6Z)jE*HPOWR5ptOC^=Z_&g;HqK@IPqqn|Jv#o<84Y zSIWj<=SK}Z^80wry7N2v@y9M0>bb@&dGO66HO_RAE}| zS;LPkOC>+r7lPkHLR`5s6~)M}wy8jP?cLLK7Qtxl_3#@kNMWRa8^3+Sk5m#s-^eoj z!WaGN%O@Ux3gG;_RTQ{=uRqbY%cz|Dfay*mBTqgP$tVeh#LPK2MxY2aCYDBEBi67Zch!K=b@C4%2S5@0l9C@X2GQ;mUaYpyi~7+Pu`7OFyEw9JsBeWU-6~3= zkM)NQi#C_GVV?Zz!JFj9ujJPcUq10*7x+$0YR_=~c>DYhz&P*w97gF_=XuEV3|CSq zX?&{X_CVZU1yLB-HB ze^j)D6S{}QhIXnd3lS}=K$eAz);4~zGeRl=SB2@Bm{FU5uV@!PQGe%0(-3E<2jzL24*W*M@$DXFGeVmm7qrUYheT@pDt@(X4yFaa#B`x5!tcL);_(}KXOi5= z4Fyq0WO(B{0H6@S*t{6#7BRHFoZ>zkSr03# z9!x4KE{#>i_&O-a{HKA=ySP%fI7S_4v`fKsdyT&MrQlcc`+x9jh~QTU5R^P2@0ZCU zhxiSIgC9ah_soCBgg*tpl}8TKs3P_$`Q|sB!!K+uDe}tLi;7|J4uaAAd%=vhXiiHa z+;OhUig;P@`{(5dl1{DlYaj)J%jx77iwmVu$K+?f3=K=V`N?t2E94YUckxdV_)o`w z{qe@fZ-4SB-0r8pMfxakhqT7IOutSDnYC4J{;m)OxJE4?URnLWaQJ|+^oUZ44Wco)AO`GpQl>Ew4u25C6Z z5m!|TGSG&9C;m<<7{d%>lU-8w^2j<3dh_SsgW{tS0ZM)kKY|c;_3gIxR&Cj1(Kvqh z&d41pGyKCHl2dJt2uYQP-^o8jBX7`8x8b;2$xj531-UrYF7N;;ukWQhzcW92;b(PLW85y>u>zZWhb1WGIMBnkOsX zen0XvnVGSXU$Bs)^I~_an_tO~p(sf*mJsyb0?i6MA79>-_cD&hfy9|l`{~%ufO#E# zMLUYcmr8y~JEH+x?h01$EBSRWbRJZXkxI=kVG+J{E66?)U%7S7Z84RJW^PA7IA_;X zxrwfGne}j6%)UtjHIS`-r2CfkNt{}(m8e~`M9GT6#cy+Ma(jY*#ND{{hu`qq&-{Xa zaGGBa{Gw9v`*Z5Ng)h8ffdjvrR`ctJ;|FK>MHHUl2~A`5hR5)+>{$8S7>i%YFZlU9 zO@6U>W~XOHVNf?YY)}viej&}xFLJ-KOYeq#0AyoY34WZ**(`#%2>!^Qz;FMG--}-t zznuI+jZntv5oubdYDW-4y?2#hT zbMd1Q>??TWH|dGf#c;8=OT+KcdttV~FMIgq*eL=3te{0p*Q6SL3+XF<;U>l!LaFzv zYFErJLW)PdbHb$v@&a{IvbbmC$ix^zsx5NjVlx$=E-B@KM!yd=eDcAhwLkjYc`b z=XUPNn?`hXogsT>8lS^3gndS_IDj6Jl9FHWE2?vz097_WqdE$<`PUp3X&TGa4t_oI z`|$fyWzQ`nk{_$Gf?v~AYzV>Q&-Cbc*f+n6A4_fg)k@=Pgq~4TbsD&j_39MoT!7z5 zWbyk8zo=j-Vy7Wt5B!ztMTJiN;aBlT#E!OiYg2b>edDJH8j64L%9YSt0FCJMjab?= z>?A8~EbsB@4(3tVOdLE#bkbK<1PB`hC1Y$BQ^M{sYtj>xfM2xSM>dnN75rn-B|jn6 z6`G6YtZ4@Rxq!*44oMQc3B#(YSPW#Yk)lL-!)Y6SC%-9bQj6D_c>Ld(bW>zfYrC#S z#uWUR_PJBF(tLVI z8W5Oyy3HH$pH+jcbhB$<^Sk(!yLmlj86W)o_3|0+y^nSH9l!757yM@H$W{Xz8U_Eo z{Bg8_8ncPdI8EDQoJ(jGlHwU5{6;pF{H*PeH@}hF;bKb%=IVAr;je`*$Uxnm1M`&R zM$i1>7Y2XC5P?Cd=}^!7^rp?PJtj19beAgdQ{-I0Iz7$806%@6$Ks#I;(z6Bk=9l? zkH^QO{73>_JHse!o4g&7wQPu7bJzZ>5hl{7GRq50Ffv@m=vXU`HKNh?wqX^HjJSHl zMi^YpZ|0$x{&T{3EEmy1IL|pVV4hpauVqcEXIpsu#ZLeewPCREOBlYV3v~JiK*3Kd zmJfIG`#fhRVxJpBYSJ=TnUI;kJgmv$rxo+j82oG~(~^STv73UC{8C9x=Y89>ns3FA z2GPwF)Xh&{JfR@c6-u~C;MXtt!FuEe`<-dv-~P-U-{Ga@0ybo8NF3zkJpyl%JZ$;>Dlg8o(H?n;$!f zFcz5*)<9Cc`2~51*;XS{7JkGsey+W%E1F<$z*uT%J@H3wq3lG$8IokwHxmP&I;7Jn77i%c*Du@z|~Y(GR5r?u@)8s-K3sIrP*(pwh-o`ukg zsp5|}f}zFaOXE;|&}aw#`I(agy}>CIOYtN<-UyOCsNCJkp0>=&(iFCG-i$PUE>N1sy&YZ}5tJ>7Kwj9_iftCCkV7g)*Tc|=*;M=$c64nG z`7Ag-P`eeHg@2e34 z_^E|D1Kj)wS|33*UJn;BSATTBS2>2-;?AeH=i?C6T$XdXxRJ+k{@Yx13?S z5xc_vL&gxe$`DtEhVY%2UvQqATR0T|yrT4}Eqn3H@K0&noWbE(Q+zBd)`$&c1nl9@ zjaKuL?6i%zWx4f(l`7jfHuKG>*G=*+qMoIGdK7nh&5;zzmgwl_(i42 z$WRD=iP9Mh>)3{9xd${vB`L8nmy1HGzVjoOxq3zLbLHY6nu|H?OOQk4r|vrs#3BYr z5IOGM=zHy(E6613+nI0;NixaGnfoyz!*i0XR5bOjewmvh+C)T*LK|JW4H%D>%fy0j-6~z^5EVt#u?2rQ6WC;NM3Be$a6l*o{D{7)$K@I zEcXrdW${>U9J1=hX|7cq!ykHI{5ryIE4S8Sqwt4o*^m5bQBs$dcEio@nFM4NUrUPU7C3$3r*wP(h*z;C4}OK6`o=Uc7NCs>%OW+gv<_#_>rM{bxb@^h zBih{t+5Uq!Z*az-L_(T(YsIm7j;zv|5JGB4cyc(-pfh_bUHn*jOq9*9&xT+feyGww z8vbkJEl1KLIWB&p3O+zv>pka}%3^yzlCI*PPQ=!_ax)$L`pRFcEC5!BAN?{*;jfHm zC32=L-SqmRu!LxtCWD`%!%yAb(~Bh+{MdS%pLgL$eBcL|Mrzse)JDvBv$D7kLBP;H zDpC}#H)cV8`&06o+l~W8dtW?p`Cic*W{SC?Ml3@49M@;Jao4MidCPpUL>NOg6lC%V ztjL>YrZj;+)&!)sv0{eiBV|_Z@QP!KT+pW_LSwVQS7K{%^9w25cfch3VjYnuTWkgY zZ0h&L&xC4z$?yxGRZudETx~GPTIkMLBUOofR42SfLYc@dP*?gE;i{D)-h=W^o&MT^ zWB{G+P7!PVZB=HwykGn@i1`X~@GBp!v+(nNTf5!V{ql{wfqyJOMdTY5KwNMr((kiT zPFDUYB6bcv#ay8gX^y$Gv5_p}w2gH6GaJ}tiw3c4-ky~P_6GPPEN3!s>gP*Od zJ8~$w_-XUk2LyW0s@qrj0Xs`}MFTJM;S}7C?Ibh10 z5TK_!d2Q0qI}x(_&du*16tqk+=s^}Iu`Tz?aXj60H*RU&F;<3u0@l=GdIq~=FvvUA=1$Qi5*q_5Ez{3&CV z4=E9XKlJK>KT;pOm;7q}cA#UB@vJ(^q1LsIR=zR|va`S@6lbpCWZ^*~l2d5}Prq~8 zaf&H55XaxQAtPFk>BcL9kwO{;0QM(a1}XwJKWI4k*|@dGYXnolKLgJA`ab+FP9$~A zW*?7?z=L%2k8%TK3@=ZAZcp?Zf93`-Q>hpH)6noEkqr3aS4QCJ@XleDiI)6KSh|6q z{>l@D1AbH)jB?tkcPZ!n8vY`p)GAJ?U-K*ZDWCi_27iAnlzd}}!k_9%z4v87e*06> zTH{w417{$FRl`D`NG(o?ii|Qk#?-5I)XYap(DlytB0JmaFu4y}Q|TQuIb-4B^})|p zuk9Wgo{K*wjwxjNIWbiD{gq!^l?=Z>n<%)YeOZsEMN`8pR=95TkzUU+MNzH+&4du)GbC?{8WOU&N?pGop7mz zty4G6->ttd3-X@@NSNL1Fh@W*1|1LAUU4nunuET-i?K$y0faeHK#^oH+u>>5az+sG zo+p53$4XWVwAAzug0NTd1Az#B&!4T)y+~PK{3LV*%(;d@7|I zG+zg*!!OmGOQ(F`s8Z%$@Yl>M{*aa8@4XzwUn8V(YP5yKMXqwG4JTShm*O)5=3r2U z2zey`El`mSY5olJRz>&&2Y@I3st}jnnFaYr0KIofjpHU8;{+KY3mMi~ui5yBYIwNz z^kTc3o#~0j9tY3*R1!%8lqs~X4JmTQl(++Ir`_RS;r!(1uwAg=SLUmw;?JaDpM}Zj zbqRtPR{N-;V)CO?C{*+3DOkGsmHY~Ra2WTpTPbxL8tN=A{1rokUm1$)EGhrC-oW3< z?G&WD`Q(S3{;KcGeQ)6AZ#`sOjJF5a(%Af8LWA%>T8yu}Ir`u1-A$4sH4p`0!VUsQ z?>InTrEkE7bFkt(oQW03AUFUks;km`yw@c&0}BF7&e)}&R7!8K9+@hYY9l}kT<#uN z3QHHyl9gS?@p{-klXwUuwxCO@tdL_KN6FKQ)N3LC9+yJMCN8>RMjE*DQWMq7pxM$hKMnEOr~3El5_)+R;(lye>#5V;8z!@$>1+JL$Smk zm-Ch@{9W^$uf{wG&>Zn9j@;git6uLeTSjm8KF{p7-S_HrUK57b7jK~TJcq70I9`q9 zx8@Yn-1lyKo=6QCv6tO4<@pQ>1$v;6e3`zhckF_;n!xe|x6# z@WG!PEqlg;%*0<+lFzsZ_)dVi*-rGd7a|0&EHhpDA+sS2^Gu}I!RFA8TD%Y&_U=kM z`4&eGC3?J-69v>X!dadh3tW3AmfUPV=J6H&sps4x4E)jfC(t$lnjx95g~rKN*q4vf zDrGnD=M-QaZJ3rl=ef#?^N3>e5)v9sl8v9hpBz_xlOWGTAwX{YI%xcv>hWeihoeJj z%Abw@4AoRaGPt}CTh>-gI5 zxiEs!A+!Yi0Wk2BEa=R0E&^@@SjYTYu)-`nL=&2mb!P^wGTQdRjK*thW^%{EVKUbU z{3Nq`qccIQH-<-)=K$CTp7dD!34#6iJZr$`LR0wymi^1pzUOwG?2I0gID6m3UCOhu)y}&} zf;Wa9i8?8sU0A@tg&R zKNN~q3%_h!)yWxdu}Mc<@GFituQfPX_)BuI3Ksag2L1$0;BV1r9hA%XKEvN3D*Y~Q z`E2~e5T5uvgv|rw)o}Sd7r?c`Io`Wrxo!BxDGKF3Nt=XL@jQF~rPJWNROsq;+4kqT zSx|>XMGPc{#&v!2=K{ZR75?1wEaFCqxsmp4D==E@nF2vANKr}red!p`s0}BWMiz3| zA!Mn#@I9g>ZhX96Mw)9SyBUKDVUEUPWc3oVeVmJKXvUxwuDK}j6F@!{Klx<*tP=P? zsD1bHT(T3OFe@I1pGea^PYdUHxaAahw*>`UxJSqyfu&&p%qqoO_B-d|l*yo}UN^C3 zC|!F-`eY$Y0e{X3c5&1`>bAxi!Io_sEu!(DiNoFz{=_8O>&_7#z;)smC^5 zl*l;zWJXvXF0IC|3rG?_SgQ$N1OM)oXdacVoU2 z;H$07jz)ltr1ak>R9 z9awi!%{&w0RW4o!9lDF`$#DQeUHS&i90G@b^p^geq16(*WlOzaK8gyG9%UBJO$T|@ z;17X6?J)2-R6@&49 z#r9e^{>g+hD7V00r(co6uXc&wv<>{KCRr2CRRvvb>Zw>IzI-?SCYVo(2-!?6pL8$V ze%iSO{*rD&7hL#1O{hNB`YS4ppRDme*+!Tze#k9d-T}eq!D@NruD5XG6ctE}JsFrTdgOKbG1sWPA){5b(R zC$xHU1pcBUKzeB#e)iz6DM#36r~+#)DbZg=xbSzRxxnA#Kusf+O?57<#H^x}^}L1T zvBqQk%P#Ad5?SLXpMk$+jsI_Ibg*GePx^ZFISgS)&+QNEcc?8+pR>!jT|e&zR9fppvyf{b?OzVK1BNGqE1@UNJvtIZ5FkGL)-<}Tq6>Fn(_+=7*`$ea-3w}fB zYi#Qln1>6^PVK>*Yu$s&J~aW=KVG@425g%hx>Y`ymrs_&Ly^gF+8dZ(=^6f7YG&g8 zIl~Wr;@1(C-qr|i$Eij>rrYzDth>*fN@#9tj7 zc~=C+x>YlYS{n5=3fNlvx{Zt>&92Gxk$t*hbB3Xbe-G^|9$>(Ti>p{E{C!S!gfSff z!=L}l3H&kaHGWA0rOP(!%7Xn`q6CO)EyD!O#Q*D0FHa;J0cN;^Ww1%UC9LPPW2E}` zFv1`3mCl~-lM!N;F(@-?M(4L4!i7Vr>0|LZ6vJ;)%~$)lq{G*aR^g6oFTLv&i!vw) zEt9OIqchhe8J*xnU8cX%AzeoUlP>?ByGK3vJl7-OY7*>I81&Y3INI>{U0o1RL%>~~ zy`oMA{0hnj9lQ??x{|MIZlJ&!1aRNR3*WsJJY|0*aOT` zu|u$8a%=~r;!v5h6EM)P_?&u;q?WDVH{}#S2R~{2F-7%C*bDrM7*)T?8ovu+IA6v_ zm}Znua?6<$z;Hrko1nSYH@&GUk|s@6Ov2v*iY)FU5X)gc>~1dJ zw%IVL>wIqa>*Nm2QTp|90sPvCKRUORwQ#M&o#26cG%STcWVa5;AFN2hCz{81Sn2% z7zW=^P+H7nrWbbxh1F_#y7|_jlZ@I^w6>_{iavTTVZ>&-4?ao!y4RN11!;^_c6Fu_ zFN{pbwIWE^XlJSM(+X61Frs;cZ|=F6jAj&r%4E#!_B3Lm8rqgL*`k5#T!wV7bJ?$0 z@~H3`(}oVz{4un^FLz|`#m)8jZ+c-^ckJ#;gTMaC;b#DTRIKtS+wdP(BgMSBgV7Mj z0{oIU{oUaI1xZO9-$N$DB8Y1!Pn$i1Xw=-(tW{6b4v!AEA-`#*35sJ#Uf&pS20a8g zQd2RXbFhM!OCwwjeDQH&vMoJ1@$!dgB=4jcP*X&nU-j@@rn9(P=jFAbDJEzVEBJaj z#gDv6G;REHXn7^Mo2ZByBennJhd(oXW=tgcOjz(fX^;SqV$5L`WQbwRePfx@DZtbG zEF@wKF}N!~h*ipSImOS`B(wO=FNGhBHGVz8&*(?|lmkDjbAc#5SG9?s=yQw%`aN<9 z!8rdN`Qit3*J17y#x0Um=+lADH|6Jh<7iOuL^&nNn_u8O(1uyem{7gu+z_#vJ?---P41)$D2+d+min zFKHZsRD^jdkHlp!pRD04#itS81w}P}tW$%)9eXah<;yX!#DAf`r7+Uv_Br@5Vc^Hj zcT^M$e-^+$DW*oY82D-AcrP$F24&$y{8SHyauJh(M9T74g>1}~E_Nx7%pfwX$QdA= z$T95Njr+Ze3;gvAy$^Jn>W;wQsN`glL)q#alh*r&-;QPY?e~4TY?rOzlD-25nZ#wB zw##Mva0_P+US`5w-v4ZuO>L{%v$vxuR{3%--M%&zF3*-3GC0zE)w|M>**c}OrH(cn zseM%M^@q_ty$^Fsa$7I^*v$LOUbLq!Lq_mleusUXX4`NM{L|sl@#T*Hy%PS*jQ+N# zHub%eUGH6OuN?ea2L3(0k7)4EzxL5)l-~vZO>^($BmUdqKUyC6FPFW^pg!$Ufzxs1FaI9-@!Pw+b&4q4sHC#WN|#dBNmfN!Q5sZkq!AOK45g-ySunCBi!*C9ch=>sprn|JCx}bm&;!jL5 zYrE}s_Y`6a`_0em6(CHPCOH-rDwMEHj+?KN%;W92u$1daRaxPL9GgRa$FExOxMSB{3{@?ws~TRV#>(hQ9h8Nc;378_ zm=&a>wI>-EA(lQ&?Kx_eQ35!w8qjR>%U?W;*x0?iVedKCStcdu;eyQrgfYSJm!9(H zOAWJ~t4mTytUtcQocwhJ?acgr)0*M@!Qk%?%=Hi6v_SX_RAsL+10M`(ni)Cj9=hny zlS8oMDR*N>^C16Z(|er%o|!z#m%G zq_%9Br5!Qafz7aob4@`+?0Snofu<@Uzvk2V+m4ugkv|wo{J#Nr$ow7R4<>(3_j@|+f6;lgsir;m+Kz>~Rmk|3b3*jcmaA^MFS!0Bg^?>)-!_2kW!2 zeLgldRW7r-g^jYDX-=q1N`5=`ahtnz-)2-H9U5UjOqbyaURp?GLS_MG}C<`z_kv;EuAqV7M&LSJ%dZ6 z%%ot(KPzJ1`vRf`1jamU)qCHieh;5L1_{sa{grrq>%@s)hqtzDa?ME(a3NWK5kfXF zH+zj#wQwQ5FR%PLT^yJ;E~63~kUt<_!bodMr4gn&>EBb6 zx+s9xgQ7lk(uEIrPJ_BiDhESYrvRS#U3hB}WYT9pt>YpCo~930gCL^#a~3Mng$xq< z%jLk=Xnb48jDMC`CrBIQ9EQkW2>uZ9*WR)Ill<*Z9{&(tx^O-BnlY0|{gJ5>Pjv?E zBk;KbJn=h$03va+sM&{RW$P{v5U%<9@55(+2p-$JVh?M_06WK=$oc1>`Nxj*8Gl9t z50Lq%Wc{3Oum6_*-8_8!qXH=xerha!P1SD?ngldZ1`o8%#IV(`2Oa^g?m zZG2(^t? zlB|>xA|% z?D;ELG;RSkOMo*3s0nQ!N3JhIwNu@8M3&Oql#VfvR54Z{86&ZU-9!dAId5b`PZRYGiX7Y zBk>Xk()NM_JuPdkT|Yv;SLP4k=P&Ip{QS+p4l2g+f{mqd1DM)>wE)*{5zk$Ge|OL7 zu=m!No9})Rpj*#nIhQfF2r_@ooU5EDme;Ap72p+30GJB!_ML+vvg__W z5nx2tb2YRBmk8@=?eCXI;R=g|u{_2v^~@$YbK$Hc^1HLAd<0hf;CuicHo|yZ3WyaTj5n$o(F_tGbmbr>R^ERKz;KGeV8^gx! zH!crX)4g6ZYGv&I{I{iKLe@Cw->Q{BzW|zu}mfKTWXnS7sX_#2uhF$6J6~ zx6KUqVs~gbe}Ctii?>3{n{*LGjE1p{9ryoe}WD3$NznZ01*_p zyi@D@21f!M8QKe}C+>XEKQtWjtIlA4ST5$ej1<;MOD1(W?>klaX8!Rly^(qiq`d=( z0H2-@6ZSmYxu$04ixW-r;7tT*GhJUFNJ}iJRndA9qW|D)*$!**v#QqL{QTPu|2otG zB9`Q>y*S4PM8j5paPlw%#jhCR=;aASLxZM0m^L?t3EMk1hH-hQ4`z`_`~?F)e~>np z<~VQ}0_3bAU*72*z@`W$72tzVv*gj|HIa+E2mv($biM;s8i$JM+Mr? z0F&m}U+VK^9|6jaryR>C*~Jx2nz{ei76CR@&_4@sU#NNX?c;$q0(`LN!}n0LisTd9mCX%q5fgcKxcOhOaU zwouflks<||wn}x=8U-O;_`_fpflACGiWG4p{1*Bbc;?QXoZOqt*!sfSp8lH5ockQ5 zAHL_l=09#fy>Rix9|91=%d<{;@ciRVneh%z5rBh43P7Lp*R9`llbrp#j>OWid?dyO)u!hy0 z5ANib!5A+tS5{JLrKIK}M#a$0Vk9TU9(}2Fbfm_Xa>x2j5lOEa<+6H~ka&D1l^p@x z+I{t%9r%8)PDTMe0GJBfrcDZCY~q{9oZw+8Gi0WOGnyIGW#P)kjQ`pE`|0}WXPXkn$>%TGG=gBb13NW>AUfsbE+s28HGL z>WjG*vy@W-fXCHz+Ca#Sz>X1v+|TF~$lg0^uYPI)aNm}I9styHw#5};jL8|PEV$a_ zaoi{w@zqC#Gg(YZgoP^`v5B9^_}@=}KVqjBcsS7B3Btei!rl0-Y{{W{57BbW5Rf6s zY#d#MU)0^x{q8QXba#VvcXuP*-3`)>2upWKOE*Xek`D+hA>FNXNP{3D&Fk;|5ANrl zxiK?m&iSj#BQiaa*a%#6qt2X6O9*S6^r?URnf@+>%3txVH4$;V=QLKPm22Ik6oIOi z1X7fMLPGqCBoPpFCPOH`P&3j`k@zQtMG6VVkFX^wxV|6<^dtj5@6MHsm8Fq`Tk7-~ zY&!Kvm|3HA)jvlL;MJyL0;cs1pZY%jt&5ETf6F2RSCH~a=obsgn>pC){UI@MC}9PITE?AvIZ;W1a}cOT5sr*j`Qg~xNUbas%d#o;4>E~w zMv-y@Ke?C>7};k;qKd5J>+A2^SC@RzH#;ozjf(yd2zD|3ST9ua?5HKIBJ>@EJq(1wb_pzRp0oZr#0C|>38$tjtNR?%T z;3$Q@cq|H@6GH)sOj&0qhg^e;l@W~@O5Y8lv67m88A9WXdXsxS=fgnz&UWr?IqtHZ zX5?_1486VLIJH%h-JJldwz9H=wMuwn#^|Ar%25DKPa1B2)5Mx5eKVLJxj!Mb)IkcQ zew-^~N6)6qOCq0>KV?fvpq-W|L1Sv%6;lWM4|DDDrrkngo2a27qH6v2v5W1plmE8I zYP~Heix&kVjl}?=Ng_n=v!ZF57EpXLqw^&s76=+DpZBrHUzxUj-&gAQ2nvo%jm5x zyLq3j-zN_jb!}fW?OIJlx^71$XXQ-PPfkkmGqKo=tTX84gXLcZoED40!WODZwf(qa z!FqQLG?OUV%csn#Ix`=7KirZ8Kiivy*Xp_43Xq-omHoJACuJ|zZ(ed~`g#!4)Adj?4Yw&cF69V-u5bw<}ort)FzWY_aP6#LtNrwV~FEo36mGgXOSbjTc zw##f1?XNmnz##TYE`%f!o|2YC{Tm%}Vc^BG^r6fKCO`8wQ?A6;mW6;_o2}Xzw1v$~ z&ypK}GPeuQkfA&OPkoSh;1iB;*ByK3xkZC?i; zP)z!zA*=jra5;nZ_&7;ThpneZX0kFtC0)fN?#1P3tgRynv;RBF%tbxGlD5ns!cLi} z5>;EP88FN&PqKrB$T2HJMZGcp>b=o-MOZ9|=N0Ayv0b)LZpSr~V?{=F6K- z-Q7R_wD+9)_n!M=JrvkiSwOOUMJB%pg5E>BF+p3|&z!5>>Q!h)ozyg<&*~?Dhrud> zf%{c4TC&@h`OsCQtp~rd|J;^NL?6+*ZXO=`)w?N$>XswMMQ;WwB{erUjV@S4A6KO- z%s2T@#%>=T8a8_^HltO3HTK|zZwA3NNI&yCQ=pBK10N37nDtxZc0MNBrpk6UXst=B z`=K|YruJF!H+rI`wv!Nw@qCQ7a6E0vE1J|lQ(6BU+5s)uZaDsNgxx<0DozCU;`_@O zooL1EkZBUk4j%p0@f5)p-{i0`K_8Lt2ulGFEF;vv$F=pt)q6>FAfNylE=VPdIAS7~ z_o%>4=P!E{lWijvP+35|dh70BFNB~uu^K&38$vITw4)k zv?T_pEl0&*$^x^fGYc~#U92fpWLazD0ESA^<)#_5UyY)X09ZWYwC-DjaqNd$o>+Be zOiR8pjh~Hs(X?^A{2we@-dnWiwc78Fn}3;i4PBQ!^f6BQ;tIGeZkHKZgxa%r zEE|5IIv=2WcO?~UY^ZF1a zlcM9o=Fw8D^PnNu2dBfoiMCZVN8*}*$@M#QuB%A(KlSmw=xCV26MHA29i7qmR_t0 z9pI5bF){!6#%5U|sDhAj8u7rJ_rhtgf0Mu~z*8$dHt24tKnO|5`n%y^>Xeg`tk0!n z4$5C}SxqPaA5pRN4p+ym1>h~Z=15N3lmHi0`ZSu3U6$@0F;Zlsq8w;$L=U7V*o0~q z{@FMR-hm#tg$F3vry58-;TVE}0#fSp!ias^IhBevFPuMS<~9UdM>q5HC-^XUm;NNy zpN35-0F3_LHP!pf%>{sm^nTz1EbCW)3$EsQbp3Gm&wFlPos6*GJ)y*RTK>6yE8V5# zl2$S}V{TbOSqFu)J+mV7uMCPhVqnb-lfk!$k&aH&bU9_p3TnPF$&A5^Pvu<)8VUr{1RAZnbWzAHzuS z4I3CToS^32{aa*sBLwc1Z;1V|_usB6qsrh|DeK*|=%Pb_1Ui)=KB*g{dt7IJr2c6z z^-^&pC=c-_uR|nTlk*CjnimOK0SI`D-1_6zT?TpWrSs|*8|W%NHO5A2hXp{{(O_HB zh$}}6btydrxo&>Ot!@~R7WGg70oP3i1FC7#YZuo$(0UdG2XFCWFE_BR-cZCw`7cFa ziy=@;ZTJHn-?}ZZ>VD^nuBriBQ&Y+RQAFxOVerGr2qYji8op*x7?4bCXp@P}`!(iB ztLod%A*Va=AOw6hO?OhTofkVZi6yfsTj`UC9T@CmkSmm55q(E@(v#qZ^P8#EDMDn` zn;asS{NIcJK@(F@VeMf9Kvju~&9w{yd)=OX&@H+vsXL_??gr0I8I0>D6q9c`_ zY*Q(Gv7E*@@;>j6WJX4*GmMbbHj#sW12C8n?X|@Aefz}_MHUr7ac#rErI=5n)7|$l z4>80h_NgHy`Q{O28d-XT<`5EG!-NJq35p(eVS69L)~_e z$qXkXOo+!$CbGc<+|^ZBjJP1c0`>CR!roi%^l%g9_))#PIx!Yg>pW`UY1ABgw|Zlt zq4B!kO+6W6Qy2QI@#9v)+r|0=AHoak<2Zi#5;DLofG%7~0|dLFrp{U7!ysW%u46fI zSf;Zc(MCoS$#FI$gLGF$AGE@P0)c4a+s4y!tkWEx*p#zcmRA08@+)jzD*_K^CUSB*EK~^T?m*Qy59xNw=fyw#LR1t#$Q4}wH*;EJ%C;@`@bA69s>Ss*1d9Tl%>K91q)D> zm-|x%rMYV1^0!+sS({#l%>Zn$IIR}-k0)FD5*yHCiyukwrXekHJFhj#v_^NWSoG(4 zgMFelvxK4>qfK;^)rZh@=FCJ@Uzs>EfgcPk(cI8YG6gNX488@;nG)o3leaw9#M0Yd zdEEqi?S1B3n@>bbnxIZf=+=#bal!hnfEt?|2!_{OW_VdZ0xN|`QJH>4)hxde{vZeE z!r~jZN%{lHYy8W~nS8r2HteIiH&{jl$Ro6VBeSq|62W^kX?qz39A0V{Ds8_$tGWy&TYVcvr+P32 z2_et=vMg(YXs5*vqa_xCg4HJgr;N~VV7V791c_VW6`PcGP&O0rn;gkfL}#R6kflkL zL2Cj8Xf&9y=9?yD-)CEXW_t`cEI}d{Y`YVCjL<8fg1x2D$`PGXIzA?-c|jbw(j-et z2w{@|F23ZC@(oZuhg;@S7OSXy6@V;k$yk~@K2zAhCQv&MeU$742I9nkEA`m?0yUoQ zD@~Yc^DLc9ghQPSH|5NTxaS(z=ACbdO|q<1R4mPft1aeW$QxW1shBW-p?&H!Ixp15 z)bRpcb?Zx)UlaU*B~2Db{$PS#9Di2K)10HgyyRz~Y9F^eH?wxYF68%OXs=M zBqXQRXL0_TUrqCOK?fRnxrk0ki$yN11w8xJ8;C3SB{r zBlo}|1;*|eUSOdlrpg=D{%)!RQS8M7gk0Jx;KKx6?39~4CO1qvnwtJ2WHL6fVlLjy z6Jut6hUk6sIl2LZ4{L;faJ8>(Eo}pa)yKzFJI}<&5Ots`p4rpm@;+Izdx0 z%#_gP;UrUQQGpqiKtGWmCLS{NW2W&jIfDtG!M z%ZNzCfeARiLA0w>V%;a8CZ!VJy|t)?^NHCxdJ@tXh0o=fAjv)nHI3H;wj|10BM!c6 zg0T^0jqn`o9EJbfE(Vmb-DTmktbe(s{Bo(oF=L*pq3MOa;4Q@Fq1V%H_`wQpOlU#J zh}`_uZ155-ga<`>rx@)Zksn=H*>%1SfvgsgM}k@MxUx$YC4#G0xTQRW&|6oPG5<0~ zGFuKseYk12%+)_!Nk-511yuWGxeYNaj7CaC0Xlq7zxsha=(E_zAcViEZ2}f0`W7S$I|@ z{R9Gv4?V0BlB5P~k0yQ%M;q3l1DIr3Q%~Mxhb% zsX5^6$!w}S?GYd@RyOy3&U*1%75xiSwhq#WxKvT37j~%4tip2Jk51dWKYO27_z26D zX{?gb&P~hR&IWr!&}M*@qh-hCZAN?!-_UEO{2V4wSS9H=!d8X}byv6l{nDqHT#2Tf z*!#xsa+wQe9-0YF2$y$G zo!4#mwi%M?x#+Fxx_^`j)V%eMRZWOSmj7dtolwGmANFNX6+rd;bm1k~=+uP^Fm&L0 zm?jGjdL(E#ZT)Wn%w5~*)hY2vOas2$vy6c+NSQLiZRu z%PfdclSDQMmdroXJgE};u(qV5InD8{!d7QFM$+QLn(bzVz)y3_r|$IT6tbT@$t5J+ z1L0r(NZGcDcZvMh*$GQJ_PHz;yrUfOgP2XEC-(ApC->@pH%j{RE*(u-Z?ompL7&E3 zvO(!IBwiAGgzd+)Ovjf2OY?4=E!yXK$#}q{kyur&5nGSLE&r`3Na$DFn0gWIFKP~D zS&3A9anF)s((W3DBy5mJaayBJNRES|hVnKck-@Q&d$XN+e}SqsuO{@F@Bc-VGe~}Q;k91G zMWp^iMC6q(z2Dq(_jEM==DlucDRT!qd-U|w{5;DcPjUB<$5=9HU z(qsiN0y~LTq+;iH_7 z#u(-7&9^AJ^|JC-+-+JI67--Wb7|)oEQ)MlAncv%+>9?GE9RMlOu0} zI&PyLlNS|V(8kF=eUxCP7`6~YHFPsPSObRfyqC6VmCg59<7*bJe& z{L2LMoN#d_hnj{r24WHAq}aUnJ@FRK1XrTl*KCcI@Q>8nFX*WbB5u*Wfmj}6(xV)+ z2z{FA@j~Xhlx&1h`o<_oAQYQlVT%3wVWkKLw`(@53#q1i+sEi%huz!6wX>YlA^SA+ z&d8c&cS>x>!Tg(K$Qmp%ijwsO9rnm8rQ16NT(KSKbYo300#)Q0rnl3lTO+>n@&)Ki zp83iW^H_*0ElmuN9lYO$#~s$?`N1mxJ?r+)eGAzRTt_#9F$btu9pS=_o}TvCcxOh* zZfSV~f6?Mdz6`%bF5vMS!fMycPN05ayKt|73Fd>b(g*&b45jZ1212DU@RNrl(d7RW z-zpf(qfG66Mv#9Q{i%0fp3{__iNzwXw?;9naqLJ@n_Xnq?8%0eSinm9F`VO&KbO!pspBmt|*#!f(AeCVwFRHhYdAU={~KlBH- z#~ea?KJVcIig;7p^w@`h=jz_uXs~Q6lA#Q%6M2D=qO-%1$$0|Jh#N^E@;+kLUx*eS zOugF^H2^Os3Fm*oQ*3+iU*V)^-&lZN4%yWL95h!Z<$OH@-4TL;1*>s=9ploeKMT^L z2Oec=76`jF-SOw6c4#Q^MJEno5ftpPNLz*@d^SyWpUXpEP`&F~#g(E!o@|EqTnUdt z$u&arf*%P3{~w0akg-+R0BMh{#Qwh`03gQ*!SFgC6$|tsm$J{{+eJ-_=EI=3QyIJv z#x;?7a}%z7r~DP!`Ax{7U2Fv+ag{|!G9yW7yAw+B6eM^SboVfs9&)J1NWB?3XqP$P z`xR;r{{L749MFiSQq~&YFgZYNG@mn4SAbG&%jKrEndk9TJo zYXk878JGGzNt;FAx)z3THKU&yS#0=TYZb7evMc}}Xx#}gpOXT2hrNt2JebZ)rs+pU zqagbc2sXBz+FRThB+D$7KBLl)8qGLZ=V{t9dU+HP2vEWx#9s>ZyHM5jP@(iy>vBS3 zW@{P>JKB5MYAPkX)|_9w?5 zLGp6i45J8v*YGEv>aM{>7K(+uQA>CUPne$#bp87xrb0$CHCHApc`!N%Wl&SpK^6(F}9Hr8PK#L$SB5%~0#E<{t22v?+7RaM|am8)&(K93zq zDNTVLPWcqak)$;Pg+=g+=%==AOEIdW~I5&$=Iaeb5H&&QmrC< zw?v*{C1jX%E6h}OJdoy0l=;vV7r{QKno?Wp8x^tq8YpClrbrst8^NOlO_}d|gVFpm zZ3@&9tPV1-1_;7%7xuE--i6Jhxk@vwrAJ%WNo?60h_on=1I5#IG>Oh$T__t{$)N1E znYdWJTqB0KieU}@0&W20ewQlh}z z;DvJj*N#-KtIG4oDI3vuiM9v<5@j32)kh+Q$uR0LXGuX9&Qh!>yMyFMtfTlOq+Du; zL7|^$-7fhiq_a3vH{vd^#Jzi`r9})m_yg9(wk5&cKhd?iT|R7tne?}@ewiR*O~q{aJcrX=H?WLWnUcE z-(H7^E*##JB2YOpQmDRo3asyb2Nw)TNKlap|3L3b#|VRNpQ*HJ>O3q;VzFmR-YYKSF&)&&@OZXs|gcL(P>U(g?Sjsi61O>~JPdz*1jycWmN`w-1Z}>wh&% zBtmz(n4mmomLzHVRO7F*d$IrTgXBl0V32Od47T@b(!89LeWQ$fSkoMvdGa;}l0b2| zEw@Es3WQiO>fZwH%T?U0mhTZknruT9WXWY{T^hr;S`%HX|4^r2772=dQ{Kj7Zr#02J z7plhLg;kW&_tTWiaXK@KM#D6ftCqs!oaHfrZyz3aJ5zD}w)8^20Q$MX7@^2Aux^tyi9 z2I@Rk>etSjdTyp^oVdW;=4lff;{f^>{K6h-0IDafqv1HK-7#w0*w-!r^ zV;mQqqFpAMyfo4Jm|`eQBK-50pth(cX#<*F3#ap6e`GPdH7D6MU;XCM(|_N~z}q#n zpX;zw=auC0d2;F3xjDNOAY@edlq6iqc91X*j~WeH1JHxoQoKL>8UE1;j~xV%X=K2~ zdo4z!w^uqqBoG!(MNwVNd~j`|Rknlafs_@Y%VT(`wE1O9>IkpsBtrc3?U{G;=1F0H z_ppkZSJjKI9|Q(D0lh`LSY)T&9ryr5BaaBI>JR;@WMv5_d-wSp$NJ-r*;yJ<^`TKt zm#fi215@5tx6|ho(79QqC&WX%DDt{x)cG37Z;6YkvV*K6V=F&j3OvaR zA3_~`MgVOm0V%cutZ=jfm|CtIaX35eNWW@4sFqwy6C| zSx^6I<~kwX2?snn4eJe#Vt)k*8;PL<-wG>jvMQG6As$|sz|&Ark+`+8RNQuu9X1Y5 z7;V0fjk^yEGLx+WCq5nruQU*kpc&%??m9Ht&6@hp;!~-lo^dEg_?!A9#YL>t-8mRv zT&D5IuA9Fyq>0lJA=i#8U|p|vL_?9hZeCwI3Yra;X|!JE;J8{ry#$547+aEEfk3-= zXHO*i8)jQE?7+ct8Pu{7IB-{R192GC7dl-0I|7!NX=LD4eBrH+#%08*--GOTdnTWK zQ7;3ji2G*Tt4*{`@!S`8R`)*FPeL61y@^-`y^GBwU zYcb$X4JMC1tTm@mQigIyoQo%GXGcnxFwA3V*-Tv&!Yq4HQD%3|ys8o<8~;nesQlwn zWJ69@OrBH1^lI(3^X>h-nu8_^gD1zs__16llGO2=2>JKj{w$oxaR{ox?#c^AZZny@ zA9OC2Qx66Ri-E0W*-4Tq1Q3kkb!yO7sM3_vApN_wND$B#q)v2jA!I|E_yoLZfivsr zqa$O(;5lG_1E*A1G5LoUBa`gEy_6_~Og}5N+y*DFk!WlB)ikzJCLY`b7xm&Z1p8_J zmR`qnAVUv}1~K9Gifz^YarEP(#-Sli!WA!;dwE}S*NKbnQCwpXaF~wRbZmoZ3jCFHtUY)5wOzC72RYbm30=-p^#-91J!rY&s8@Xbn~-C zuSH0BB{$AobTSFuS0)(yjDZgG$9&jP+qhQPy4Z5~_(alc#By?LaYUZ8Q3IxLZI*{>iV zv*3%fhbRG%#>9VeFyTO26XR^%KQTOK?sav_oTLD-iJ-vNxFL$Uw?e-z0UYopW>h~R z6mzJ4gq0IvYY;y1f~(!w8_u1tjcDlyTj+{zo+Bk7$N-llbTCh=6+7xhCBtCd-M^w>zzm zKH<|t#vE=4wHW+ekjAQ+DJiO@W3HsSTsU_?>~A^iePYyT+#4a@o_%H?TArjoAiA#nU_7!_q$(#U1szMms7R!?be&>I|2W)l@NP|5Y)IplV9z9?6VyRg^ljld@M z<30GvS{t9m||8gBNF`2O%O&rtHEAePlokF37S0m#%0ix_F_#$xX=eD8P zG{3?yRm#~C{i5)MGV9EBt%CMt*KRK)X8bz5=+oPJ? ziimnK;CAF|CCuRHKd|%8*jI@Cd%pp5H%ZbvD|>5fP&%3+p^Ri8=E~9L#iJ@081|Pl z57zF!`2}e71YoONh7Toqt?960985QqCL@JQ+5j4KUxCu8psOs@NDE%TuYZxwd{u^( zV}d*qW7eYXY3oV%DSs)fI?fC00t6U#7pz8g`->KQXECiGvf3MKnmG~}{dzNohzr;zLnapyUqWea@wGX>%?4I%&W{?X))Zt%Z)YmXG9%5gBT3jV_rol%B1 z;zWXy@7DBcd8nZiUW^}ABlrHQR9qy!5qW`m+Z%wV2}ibS2)zt7j!La@{fueFo>;WZ zDT9hbu)TqB*xxU&8E&-}$A77oKV1CoqVXte%KJ;U>&vzBZc1`6$qb*qEyVg96KQwo z$Nl*NRl-d}nnHxSY;?%Mb6XRJZWmQih3K!lg{`rb&!-C;NZ+POy3UCubrC9i`=9Lcr@ zK(2rB1Af!by6fp{AdJZunGWZP=8!?L-edxCEWclh9g76E;*nvPYYhke-+k9CgRaLqfV-*F8CK1(%T@JQD-(J@9&o(skynP ziQd3@Kcpy;x-(2sM5+a$F>(Sx{|IY7+BH}XtGar{5lz3;y_ z6h1@te!tgqS(`vis_(aZm#r)H&t~A+^^=heV2c-yu66Fi_b2EVVDMx7RUZWxWhB53 z7lGF&UR7K(z}@G=MI!QoKwbGoo=8OurAwRS%`!qkW_?cENmjPK*`=J*bey0#^U){g zwn?UxXV(a6@BtCNUr&AZ7;fF2fGOYA^#-CBGc7?H6fa_!MY5?Rua0>J@NN)K5#-UlM#&5$ z{|XjbgIX@6%`t=Sc+|qT{`~X8;F1KkklN={-dPO~BLY;31unIAis(ANWUiN@r6N+e zO2cEhHQzB?zD_b1I#nU0otMT5OMR_MLm{0TSe4tCRR((-bTI15!7oJYLJ-8@_U8N_ z`*m!}SYv-8lHI?(V+5~oe>rm1}bN& zq)3f(uC{}kQ0~xs?#ab7hC=p3Ab$HHEF0_Mk40TMU{@Ti0X&x5U)GP#m%Fd$-S3wN z0|u8O4^+)ou9URBdQm@JS+7njz=q~!x5>1E5j83c5q1eb-eOnFB#%6Fm!%XylZ2(3 zsgGNSO47u^)Te*(Ejd#*_Qy58KaCZIN-Tcdt~RE(5_`Q4G<>Xm`21v!2tkpOvA#bu z@i3w*ir6YTDE94wbE=DgR#fJ(q!*@tadD#Q!|b*ge;j#!>cN=1QoHcWH83iu1K>g4 zzNEo(02@zA^oz*jUm*A@IzhsxXNum+^}>Vt=ja4uCrz8hnHm)d_pG@ z3kCf78@5MH$PD~Fc&Ue!LZX9L6%hWZ{|xs3hk#~ z@@fREC5wk_Vb6#4kD2{$fFcuhtlnyY`a|B}Jj9ohPrT{>LAf z6XA?!y1#o)Lr=c8eSdeRMUw)RCOr*fbzJQtL?0$3{+O*8!8Hk0JP69^E3Ql|)FReO z+}(>cW_rKJQ=AHXM~lDs{1Cu{I@ODi{l{PD!)R?83o%CclUUV!S*V*wn5YHg<+s1g zSPH`C2djLjl2rFM)W^LO%=`RrIeYgcx)q$eqqqU9 z)2a1P%ibd74|6(4v_7!5jmz}obZ5Hv7ho!}k80iBYnIQMKiMEa%PH|dOJa<7-H`LM z+g-k-$VQCud?#`L<+Ogdn089Ab@!C{a_UUbn|ZP_8*40Q>{ScHwPxKNx%rsyQxm)8 zJ8vVTZn?j0oD0Ko(A2n{U{-zpP2#_y-E>{l+tI9J(>XzZjz{J^H8mTLjXU29?7sU& zffcmZb98KkZCW*!YS^~0`!b&`|l`?a-^T|y)iD zG`R0+=hah-VLSVg^W&9QeHX^M$I(VTg*87h3xS4Z$6u9gUHpKQ&wa#4>|IBMLR>nC zVU;KHY9OiMxlb3c(Jjau3?x%$OB5hT6%(1K?16^x z+*~Q7xuCU20KI6$ml`{%BLPF|gGh5Y5kURe3{X?7{=(B_8^T#LJCb`NW(R1}UL>1t z35_nFb15AY!n`GY*0)n*4~7cx78qkyieMW)-+YSN$wXsiRQhy=` zQ-19cDW?S-(U^;!9*j#&q}PtX#aW;BMX*hM$zTC3QAou7#F{X%8KkGV$n#F|5E{R% zNNTydq77Y`?V#is+?nLXGizhzPrIgYA zcp@O`@JB|}q6aiEZ0T`is%QMivE$5Co`fPcb9OO zI|kMY&$LoR?&qEnMKPBfyNY`VAhJG1Pb7QTo^tICOnHnMt%;&yrzb19TblV^s$eG8Z#OnssF`q2AGmcVrhD-hJ=O+ODxA<_3cB^6 zmjgcGE+r0su0bkWdbWn3r9&O@vn2L7$PZ2Lju|yxx{IoZ(0rX@ zB`(nb8I)E8h3h^63aG#VuoyT@PHmC&Ww)lJae`DMBS|8G$%PgyeJ>aGBO@k$i00;zbMtg08 z0Y4BlfHj>c)O5wE09J?N7{rl%f zLOKLF{M($?)-yo8pIzLgi9=d3-(l2bu`)^Z}o;93bc41JyxoA+!>io0p|!M#L(DTJ36-ZIw$e&!c)m z>-BpOvx&B4gYA$@K>lYobu5Vo3?+;ZWEV@tCWkG2b0mrxUWRt?AS`9YBE`ln+rUj6 zrP%~gO2VOWqAP}fMy=x|QQ2s{pPKih!?yS~MC8IJCj251T5h&{WVsi(80r2+ngupf zz(-up_OO8S_7;72A6Wq{q;JhzZnzv$E7^7J-0ae*g?W;UTar!ybhr$Z6f!0L+%=?n zhHhb;75wD*0|>~MdY(@gQp57ObS&UB0kAe2YPeHSbrW|5Z(d;VT#<7XKHw9w0~W3R z@$LO|E)2YYK>j#68D`MEGhy{|iBD~MS2?{ifwP-{bbux+J=S1K;?Ym z6^B~N3#G12W(hbNm*Ux)qSCw;v}hGB&@EX3`7ZqPZ8Ih~HAnXKn=bWv`6JBBmub+( z#J|M-BM1|XGo8ryZzu-TMh^A zH_Mx2Qe)Wl5z`GL3}#n7$bP{j=#rA0pWh>f-oqSdD#4aVmZ5AvAN?>|WZboo$3vxr zOH7VFk>x?$SL|p)aO8q5!NmyDQ}(QDyAR5VIibwrBFy)pcUC@N@h{=RaZHVf)NPjk zd2j_05vtSjk}wWIJ68S*b1J;sMcT09rhxzzF|*e>m;)xq zD`*cJfg4$Rb^Li3n;P?V1S&B;fIYQ|*oIc}nnEStDMSFU^o&+)AWpQis1I#gR6dQ0 z|Ii*g1IEI<>`%@lbrN68Ac6= zRtnSL-c(YhwG)30qTJU(5H{8A10sJXGYDQxeEG7!* zC*^F|;~>qlb!DC;Tm}OQK0Hj3Dh?X30^D#v zW~Td+aSl+M4%O4{I-5BLWlB}P9E}W&TN=L?UnJg)aT1Z$;IFQtJ+B#$58V{k`<9&| z&AY*%%tjS9<&2MD#WR~p3|lEGu5m~!^VS2w4+|3ChQXmFbf%UyUf%2yXGvgCX~|US zJLV+sl%}d2t%#_Z{-%RvLG3Ptt+MTtC*O@R5xgjGZ*PpC5MmEZ^-l+#a~A*`ONzns zbu~iq4-06lS+`^Ur+sv957zvJ@E7Yk!l#IrpG^Lt1cs&XFQ+NA1)_jEk&`9;+5+49 zzE%i@>wCMBeY(AW42VtRw~1ekF^65X?7~(Zcg$~ z3W;Mb@sZ-fTGT?-&r*Gg&3GQC}9l)k!nl;b)MqFf&=~(-#62 z#E|&q>6Z%Z82mR}h^bFo7VyGHoF&<1f5o+=;z7LL7-MQn3Z!B*_ocSk$3CJ0Keef1 zsN54>K?!OzADet*bdj!Hk+0CB-D|99xLyM@?yl3~dnK!T(@d9&#zO9S$iEpJ;to;H zZ8|<<{A*aqxg(_g!?`HG3hn76_9!V_W)1_-DoMJC53S5G4~ZUFeNVqb>cHmeYGNBm z4({g(8bRzaMIwdiJT_d#Fu&p1ZUH>JOMzfG_?z+RjqYE{-~Y7ZzVC$O3G!bI#eU6v7$0!I5uBmoG>-JTy3Ke|IKk@t=S{s+!J|B2Ivd`jKL9m;xTpVKw z`Yeft3M>(smH?IstNC`G{b!$Z0Wh`uy+^e+fB5)z3fif`3?pwzvK!>QjfaAM-V(OU zVF^>UN_PO@-+26(2f+Vil3Q&FjXa)nU;b3x9MD}0cGBqLD`aJsNG_*8_KVg_eukUl z8y-_%prOHE;fQXNj!azNe`drU?uu8rv=JUp{8wNY$49)6Mxu{lKlmZtnN3Jq*hC<; z`fZYO$3+T5F!iylbSi7%$Xg1)F9(^O!AdDEc@wp+5)2p4bic1+jA4g&F)1CWT;rQ9 z)}$Mi(f?|c?plRY&-8-42eYyTO=48lc}t+ zC!HWlA`XJ!iCqLY(XDCO}+(|jy^dzMj`WJt|Ab2hJh0 zIXF09ZCF))ih2LKdJ0+XDmBV5n zZv%)(=mVi~9M+k*zzTp`#09jP2kLg;7bH3ujFJa{C2oe8k62}qV<7zJrKzR<&p?_pR;o!WR71yLA=T&3eh`!@jWF6dcsw#uhzYn>S>TG6?k zpWK~2Yb!?-h8I$XR4USm6e1*~ag|P7A+ALVH6~3G5Tr?AEKpcsM6i&M4Q8>C46*D+ zg&S2eR&Q{T&VMUsX5Z|SIap4>CPj{QcfM!mXzx6n`Pfky0JmQR;6tcQYs;4lM?e8k z%ZR+|794pvISTvB^*&9$xqUCtlo2By7(yVO+_yvwF>*nY6o8>pX(AJ?8Um*f1y)pv z{sMObdKw-LIc!z0zuN0ot3gziH@UeV=GuoDKlCTJ6eJ>j6^?y?|04@fCqnN6U>24< zihYIJaYMxV;-zlYUYe^V(O@>%B)buCqxIF8kd>EAmnd0!Q4YC?%D4}?$VhgC!w>)< zvwd*}UjRh*p47J=nrIr93iLIsmTPki9}P917UKM41Bux2U6ArZqL0&&_%_tv4M6Qq zI_60|HNulxmQQMvtZLUE;}TE=;FSYlsI9Qg)P_z=9qs_2I}}htKxj;kLwG@Cq}T;( zP68_Q^K))R*ptEAO`&4CKK7Z;bpA+AQs@k9$y~Gy8*a&AD>ht`h64gf3b?Q zxG!3Ow~?3j)$ND+9_t=}(avBU697a>7Ce|lgM+(S$kZK!V3Ab)oO?h(@lolN!=X4- zQ9Q%|KrF&A%G8~NQr)#=NCzx*JWTn`q+fKG1V999|c}koruu zCimguu>~H8B%PqcBYB7}EkGTHpqd4pxT$BG34kOGi?FBhV!spYz~ym>1AvyT8!6F@ z^PaTOAx;;7=o?5fMX4zbfl%kct2aR!f8Pg4o(h1?wYgUzRzqMtsq6Adt!_X2>&9Va zXwwz|WyB{#;FFH_*nCm+;!XJbO`~4SGr;Bct*A318m2{i&s7h^4pX<}Cfv>qYYR@@L93=?gK{*^8wp7b{n$H1b#MN%;L&|f}E$OW;L ztJ2AoEyrQm2w1DYZ+RCO`sLF%BJll(z}{5(5beJUY#QyT(5i@=T7L}GE9+Rtw@2Em z&~386iVUfTo9r+EJq^;HOdfKPlSsvA8;o9sQw7S>ISCqeI-Tp#i95%8vV4P5>-t{{ zI!|bNpJ=fNrmk+b6HKV!8>o*fPW43NI9gJVR)On{Iu;<6Cr`>RR~?kk*`#B>>fzR& z0`WU0mTh&{>0tpj%iZJaZm7Ds@nc|Zgk~5Ay*LG00dVDasZaugPkk8d`7tL#Q0H<1 z2rns)frC<@2?{Aau0sn@Xqq6*#Ou#}+0F+#cj*toggx{p;A~~|7esYQZ=wiQ%y8Ho zw+3STdlvwXr}^~sR|uFNouBuWG@8ARuCv@Ov(~uvE&$AA0XmA(k< z%c6f7@a8N)&8KfHhc*vi#GZ2UAr{$$BGitU=wYF`TCnT&jGf^{roLB^1^&q{2*KX6C^jFn%urYan$ZEEuuDzkHwG9SBGfY@B>&4`9{ncne>L;tu-@Ur|yA^^>|EsA0h?ogUYu1Pxp zZ9)V6pOi;a{0ibFs7SuSuIU*MMAhT84|TB*&^@Vx$nj+}K7x~=k(zBgpc1)_75xRk zwg$eLO&2c;Y8z-(q5!)-4+<=Qd3P=>mKV@+~B5Mn-C zCBi%+O_kjyxS&mWKRLuE6j$RQ7y3gw<#+naPb4NJMyD?FsLBf9aFHYNzvM84H!p&m z<+J&?pXUJZaCSI9n~bNE{^%$ljL+x!kK^3J^YP^Tbe<26PJhkk)A9WDodK8uz{EJ| z-o!ECvXtAsY_W=4KW>V69Fl=#e>gm_5_GHWNub8lb8w1bRxlR?JBVm+dmYJ;@& zcItqd$?f9>tU)u3Einj&Vr0>9w^oDx!k#y8naYEA=#K)4{uKy8?CbiAo>aP9f1sS2 zgyk+6`@9W+NIp0}ot_guoDBXrJ^yhunw%X@&wn0`e$FSKj?JBn2WOuSW^<$6X-FO$ z07w6U1=vo8KkH`Jd?2{lFFVbB(yi(X4$z85#Bo89wKS-(VK3}>uO z6{k`NULB55iq1k{BlH`Gz%4WLrmdqs06gl?r?W{up0y6m9nYo)z_M7u@A#GPDcY9@h0I<<1RyrHD z?V65~)QzM!_jn|L5?o#V)IcV-!R%2Hd3jPFtD|f6q>k5@(r9@cV(dX){38JC+hV1! zsGhb?7N9z!u;I?I!+@!#?vvvXaDWYjqXK@umR)!(yeLV+Fp=i= z?hd(*mec;?>G9_I?l=Z&g&mg_JQtR3p0Xd^ub0d9-F_yi56dFIyXt)}#*e&)3@^J= zu>il-5cuUX?Zw^QHmV~KX6@^T=bOieYauSxw0rds^y<`H%e{DTA;j&n?C=vt$Vb{o zHwTFL_Gg_3dr~(%srvEV^G$fTv1G$GK=)ipg|nglKLlxSZ*Oj%wq!-(iO{=`1CW7& zxD`n`m2m}e5XE7^_*7>yiZE_Xg00A4q6~0@WETpJ#J+S2Sp|lFzxa*NeG8p_+8^ir;BDpOe(TJ#JM=dVVQ^{jE^m_ z1DK6GE?}x?h@+)B?-Qv=^QsFF5{Ln8O@R=U|u=oYB2ouTX5b$B! zr2lHje~s!2DDcvq-qw531%N#QTnK<~UOc{eb>h9i0>D|plsz>2$fTS&ja?2x(Edr}fjMk&4L8K9xxS(Ymd{&Yv&Fm0}$ z11ncQ4#1)n&Ta7{@M9UNjzvaHXK@=iN?{z&)7W{O2jeM)%fWbNrCr_w*H}+t2P@D) zuSUcFOoO8-{l{^PW8s?V3($)h)V7TL$7_rIr~_0T%q=BBR=5|qoJZRsQhHzh5qj32 zjRAnuG!*}Scs2|AlDkxB`810Fm*}Zqj#W#`=)gwbMt(M^6?K3m0-?-LY!s*vL~21A zB{QTLIG}}+Um>9t-~2cRq%}_Qf+1E_Y`DiGK>{>1h z=&~rc000-zqpxp2KUfZcWIec-h^rz-15$j~D)00mN+cJd`aG~k9FWX19dPX1SEL{_ z<}glp%F+>Jv>1w91gVI?TL1_Rx?uV*zB-^h{>N2t4)t*OHtZA-((V+m#rz)u4(W|| zugbqddImTv0(|oBY57;l>HrteomcN(mc;-_9-tv1Yc+OZd9iT3du#E72hPbru4_<3LW3(w0#ieg`yY#Vu@L!tD|EweefPp}j25meE ztSXo_MS!Jjw{6W-k6>78qf(FU(?Dq>eXzGQSoE)Itm`;YS0P4gt1tDvfr37)YoJ{A zv>9Uq)xp4;1_}UMwa@OS2I7ds+(7lxK=tPsSi?YbQgcQImNc;Z*g%pXj|70V{%HgG z9S*vuVo^%sk+cIl(txfF0pX~S*m9NtD2uoC{; zsQHgSvPn7&{PNmZMNEkQNLdM>v~hWKihw~Y13VQ1uJswu@kPH2z_sbOJMGpKS8DbNooM7cY^F@$rC31;}3H$HHcKEEAU^^W-)e~ zARp!)nCdzu&JtMfMPJ_%_%3%$-PR-qxAx(bBXb4L*Rwr$a*F_u_?G~<$fD&2CQs}I z2JR_@o|gXI1_FRqBfKHXv#hJt)NM!(=iXEYD#9efCN%>DxRv?r#-K#AJ;Vg7)CIr- zUE)+1^U^LG(H{DcGXEL1E|I2R5v!07|IHE9`AXg>HiFz(TOo9*dIq>18r$}vFGxc* zhmjS}bi|1MSJOvVT!|RRyYmf%dJ?1opd@D-$N=0rZ4vER28Md*rv_HnM^^y2JN}&8 zUo>!PUNWW(RL8c$#sxGZVq^0i*EAHt zt$4wgUh7w-{MpFXb=J_2f~Ta_tye|^#p*wnd(f= z<@o}@z68FI&6iVCFR1xDcjwdEP7%g&!GlT>m0rRwsDwcgddSIJFoq5D zDJ@AbgdVn}L`9bk-G%I*i^5*^w9D>&VL@*ZeG9&c+Be|rWZG}%X&PINf9TWvcIWT2 z&y(7Z&+N?3${}z$3Gy^OJ2#L6P~Q4NI07!HaU5Mmtn5mq1ZV&l6ww=m>Ju5zbju(_ zo?rr?ibz!|L6gnX9g>nLM*JxTuMRvn_H9A!)M1c;l2mqix8U8`YdJnR(0TiOGaxpZc5h5K( z!ZB2-{-YSy0)!~o;K*kGsZr2Og{ndcQZxV*uLKbh;12EqUd%?b`QE=Tuit+!urdT@ z?B>X{AFs5vxRD>WE4{)G`gT~g!}qh$wrlO2v7Vi4uAdir;xD_|2UoV776ThA3#gb|5AZ6{9?{-7q>bvi=ZZPHA4)dg|S-x*|xMM+Ev$utWi7X+X|`P|3s$Z7C%JET3O~vnt-~F4Sv~S|tIF(X(n~Fth5aN7 z`mha_^Rrk84Fb~jA@rU-{)W}pha;Fmd%zBp05mkBs6&~0btG_oXak=~xCO#s9(#jl zc-ofuuLy-J{C&uO=%LT&5ux~}cqe+HiHyu&dILk?w|24_!s+PCk+P-p$6tMkj3SLw zE{&a&kPd;x-=Gkg;sBKSBzB9azslVx$l`wNwPL$ixKo2MJ2F_3Tg!T$1xcT2-Pq;r z9m8l2Ogru)L{?}SJ#k3r96nr+msaa6hE}8 zC&9o0NOit@&1JZDbES0_OVbTZR!if!>j!Ssj@-naa1EZt+h>N^XNGVrURhl>u@a-l zEYk_h`oJ{YUSwNF(~lf~$%t$>P@@UzwCp1@_#%|ncL6BtAlKi0czr0!vY0g&mYLby zcGt5!Hw;{FnmAEd`dFk~QPVJ-7WY4%SVe60S1wzaNxX<@XvzlUNS(OC)&}tM!PpD7 zQbr>|HlGGLai&P85GNyQ8ZQFv+YFTelf3MS7V5Ul~%Qyhv{BcbN)-+tB&n$0gIG$-4ox=274!{pO1`Fait+=by z;VlIKYenJP!ze1jk)##IC0H@diJQIH%{<#${njc%|HTl7)ZL4EY0&t5V;Jdj&kYe!Qi@jeZT;~rL}1CTsyWH55}9zRgje` zi~l&yHJ7FZMOZNS)dP7QOI;WQ+O~+8t_;Jx0mA zDvgX3G=QOqv(yO-x|j}(L1Jqprd)?|j>LiVkr8zatU_wL|7fu#0lQF$959^Tx@yMco6zUSWsCnT0dUZuYPB103NOZ7&l|TC{}U5NRxQMn{fbk zEX$izmO)rdS*rx#zzib4J9SralLPPru3+LV8RGyP#BLsii*`LV+ic>drX??@GYAS$ z3cHX%7DBbia}Vp|NSjX%*})g=lTU=3c?m!xj&0s0i>F4oG$z$)k`xm*C;`}CBz`oU zav%C@0P4%QEdbbzojghw-Ink1p`(+S&L(>{87Mxa^zovrsU9Lg(r|dd2G{&30odmN zjN`DvD$OYQpgT#tX=SiVR-H8fTTvdE{k9vJ6Ar*qdDYBV3BZaUCZ=s#dbY4w(@DJS z{f$tT5g-jxzz6!q3Yn&R)6pG3l`%9{5ECi^Lb9u(nDPh|gCJ_6WI^Dzar<~=xA?Cn zl-8>M$ig1tKQvJ)Bf2F1L*PF|tpfiz0C%_!@T<}3=&QQ{;3+Sg@i)Qo;>(kZ*_WS> zzd9c8jph=7=a=*2qx0F9M_(SDeSI`KKO3LS&Kpk*{UiVd12;v0dik>!rv3cs?^jn> zBAvue>h=A?@r!EmedfHK){CSYzbyc$hoNKkhl#ZcL%kIz%ZXzoiD7xJV@w19^=i7R z>C7>+#Os(v;)Qw^u}Y9So-M8@K)}x;gEJQqA!#Bfn!rRRvOKNvv^Ec2!%0Cl}y z27u)dIP{iYkeIF>`fRu=9h3Bhfl^=~+(hYGWOJbHrdX)b`DvYyzTg|YGYP_Ucqy82 z0BRz|9K#9Cr8({Lnn@fv{UmWrw`yB?mgstsFQ=USiQ(q9V_3SqDtl*fQ`ZLz+vXit ziI>#cUSGHUWfWA2f%19+fJ#V0{;2U9IMkvo-421yFMxA&uNl%F6VYTX37S&dpiAU& zgcACk1z?JD;y)3hnsAbUjrxz&qx^?!sHXm-3jX6#9Dpyq^TrMzg=>uF^3_NHU_%0M zJ{uo>e*T$&!-8OJ^A5OTCpwz8!{@O73J)lnwRF zuIT!qKjlrAQB+K=pr>bH%}RCNFDhE=i|IQNO@IFZO?WF@jaC7G`XG%~ooUb%87Hl@ z^E|6`tX>*S^g$8%S(541+{$>Ws`Iq2w^z-zV*z)puG@BnBc!tQqjaGE7g9d+ECHyL zGvVH~%vHl^<@EDI;bt89?lh0W?!>KzzN715E%_w<=n$9!5Ta>6|E}Vqf~;D; z?pteBl;SFFPNgzLhks50K(-lxbqTA|5!IkdRh)(`U3ILgZxeh`|DphZZ?4w?Zt(Nq zHd&({3@gI10r?FMbt<$U6sf`iNQZoMO*A1M9mbJc^mrr;XF4|lb##bGrVgMI*G7WO zJ}5`|fkEI1p)ol>vP*9QL1PGMk3QkB! zr@NEV@di8s4~f0r_>2F|{=&w933G~XcXoF34~KuvW+v;^vOe~!M1X(v&lw4jgY|ST zNidS#=gKI%7iw(s%xk`bJje)GY0#wE3Q55N3V|O*D@1~UD%5{NYsP!{+jANy$p zxH}Smq7$LdzdUK=>>SnAc6<7vzFwZ4e7x9Rom@6N`L!LpuCpCy+l!AU7u&Ns6LM82 zK)yV={`eF0mwqFL0FPL9Plfvr2(UR2bq;^dNPq|-8njnB4nXsrN@YCgLOF$%D8K7; z%Ad-9^z)KG(Mu%Dh#?uFJw=b0HOL9!KjQr_!ohzdZ5HHC9RW^K2>t#%i6yYhua{$M zop0~rKM_<)fM&N7;wxUJlwf*BI|g7p4MqrXJiss#;MAf3lb;aac!2cA6CgEty_AJS zCbC8B{b+WUPM*uv6Iz%>{Kn)`5U0!5TKbY1L2ym*3JO*)`VV5qj=BG$&B%3-|6nMdF4y!@5N0aP_5h;Y;gyuheXeG8%uhT|VRYiqbronx)-LE(!Hlm{H zl+lotqJ4U$_h^T@rN#r~IIKskR{)5zwKxc{$^nK00BK|B{sp-Juu}jqlmI^Ko_@EP z5+F9civmobT8K>qc%VMxV92KFQcU!oC1{jHaOz=81L?Q(Bb}8;l2KtRE#9e@%ya(x z5Uk+8=5rLc_umks;OJ$6cjp1l`KfTXd8J@4yWLl!gujZGrJZVPN2taurK zK+FMF2H;>BF>%$^0dCA%e*|FM0xOL`f$*9}9CT38l3auwqUxANJ9$!s5gC@O3Jrp?U5;E+X? zV|)!A9TiOM2%|a(3c!^ZzvRC!>cigc+da2Z26$^WTi~?ah3^^_Eulk`1Q{3sR^i{1 z)pB=8z)?hiRYBhnVEv81zaP+7?H1U6>ZI9JbYe)dSBa?*J7Fm2RhgV^X(McNCW1)> zaGspJlbB!V+x@s6%rlkVm|P5YAu9b>`S1P_${_W=u}AP8TZEdrJ^b?x=;>40LtVT0p5*Y z5d$FN0S1qqt_MgnUjcLwJVwo+7MNq6kZ8NFJQ2NKzmD*tUq2V3 zy?J=L*L$_52k3!JsBFlA_-PN^9EUZBr^3NyBSj;?AMBk$YwJc3$3rgNVoFm2ZK2RY z$)&KAoO%lhCM`M`A=4r(LxK!ZV(9#5+PwyM_oRhA(_nc4F+0od4Yu%Ns z*iO=raU$)`?#|A>;NP3ouB5|50iArx8GOt5WEb15?2kj$;Dghgfv?yt%R$S(;$C2W z^1mru(m^YWywgEs91hlZTcLnadF~+t5Y;4E;kNCd-Jh-DVO2Z0@i(?9+fXSl0Nd}# z;8URcuB{niKj&mmdo$lzPwO10bH!9z!%oFiwBJW9D>58wjg0Zr$6jvt(^663y5cy{ zgH6S@d0Z%WvNhB?ni-$31J!)ST^nL!8z2+#Q(6Rh+9@!$`O&fQ+J`d&JnXuIoDy4{ z@e>k|wFQ7kg2`~uZoPMEB0$~-(>3{}9Go{_pG2lM0Caa+9}(`h34r`Gn8PlLY9m0) zL7d!9*E=X1p&bj8l5hzCsODc?#()q|z=|IyoAsh~8Bkyoln$1N>-};+3_!<#8jXLT z$*u*!nrnSYoFikLZainM4-)}EnFD|Eq>CV1!oTIkUIp&7-^~6W{y&NUzpRe{Pumm$ z@?%mC+q+cf@ghJth~)Yrz{9qz0^`qF5dk9AI0CG9kO1hoc@;jO}YI+8mUP4EdZ9rUkft}fX>G(R*m1i4N#VWNj`n# zDKM~CMZRUX!Thkl*FJgk?F*I8QP*%`hXB}VAHCRNnsE~7d7OLgqt3TaDF9%7#HY#$ z08sfp&GhnqxCsgUM901#sv;8f`+d6Mhv7Sp^-{&%8QwP+; zgshx2rbBjzax82V0AxcX3WadMG&LpC8fPAylK9P<0<$e8{*o6~bGX7arqot2T0PAL z+0`N&p(nQj%M|#L00;zP#dp;J{KNoo_weY)_KRdghXz(`1EhU0dtJ)yeftunbMj;R z&@6V-NT0!fEPCU#Rak4XmsSy+#=_* zYU~#P3^UCJW#J$-2FO?ugJqN=^4qV(g}r@#bq~)CkA8iDqa>kmQUFMS?)DDO<<+0@H2 zI^@PpQ*|DI6I+=BfL0C5s4dNrsBjIC-wE=?$B%d?$QJ)Pwlc32fb^Bw>vY>kM?XGC z`E`l5M@L7*KNA?yOy5MhJj|o#gUd{6aVuRI>=gzxlVfW3sYb*kwbv5_e5|EL z0>DAhGEYgz-9{ltr;N&2$L5r1oy*uw#w$#)gE>!E14=8lHM>TmN&pPB7jl0N01{J6 zHP~q3V6&ZoIJp-=xV;pYdkuMzN2xd{L<2d2wLox^VX{O z+1uTxxQGErWO$jwG~?{-?i3_7R94G@>*ayG~feeSR)Z9Oc4%=(m=VgVBa5SRW2WI~zxlk9-$#%8f) zkkOYi$p>sJF(pGz!s$&cf?&;A2U3TTIS)YYO-YSS+h~K^Mch=xd(jDivJFN6D1@Vf zAP{+|8UxUNnb8`=2HNLs~R&UN{NY8@P%(zVlKX}%9O4q4E_ zj*-2B3mB$RRyzm_-9`@DF4QCAhq{$V58vtHNc!}WO{%=k^mv+2z&8_2{DyflJVs~5Jr>(h6UqQg{ck&iiiN= zVwk3(AVmP!*JCbahKff5Ca$3-(8H+4rnRT@h`TB>L+*ibXaP_}5kqCrt|@RbsXgQx zVpJHrn5f!|fQSG~_X2+o_zCM+I0;n)02G~) z1Dze~BRVJ%AQ%U6-7JPY0Eoy+3Iq^1E3Tys6O@^grg1NaDZV17pV1C)PtLOtmj&WS?IUY$nu8$flx6t4Mne0Fo{7$p-*c z34k7I{HIa1{2EAU3T#Dy;GXbK(1#LCf$Qmq7nftZ-#Q~e5RlP8viqd6zXp?xg`I6P zMgcvMe*gJ6xVmu)=Z$O^<9teYaj5XgART+S@ zRwEYx5!i941ps<#+P5MIO(kGspkv8Ku08%8$~1Z+%>YO;4?qF5ZB#@pw;+N$LF5`> zIRZquH-8C`r@&3#W3*cAxs3sUm<1aIvh4E#NlT!|8L6dZpH1yMx~z5(3g{&)BH6$& zYovNq%^gG`6qWrocaS!E7sI5#X6-oPATln?sL{kh`x;;~QIkawpPSkMWaT^nWm8-h z=xGopssXRi&H*svzDfX803h5pQ{=JEX90*Hk0;drL>NhEMn_~4<5rr=Jaw#xzDQ^T zP)YY>t4QWm07MPjsJ+G8)*x9s2bSky@dHsE5uhCcN&)~Jon5pJ&d(28@7~e*s&$=g zwcfuQwGQ6X8-j1n&d&ZS0>NqquJTS0gn+z8K=QQ_KCWFFbw!>vX@zFOzv(#5c9;eb zY|xd%-Db_37ycl_{6RQB^XIH}5ZP5b$dAOg9%{Vs&H9chFaU_NvH-}EQ3kffL~2KX zJskmK?g&uvs&Z~F`{5cORlv${qBHF3VXjI{mSUV_V;Jesob!OrKo^T60!(DCZCwNG z0f5vut^u+RZm&o_!W0<$0Ouk=oRKUN0N~YL-1u`{d zmV?lda^uoD!$lg5L}DI*O1S|@rLzDWCUgLxI6au4$^xJYiF;y+;AR654zck}_W;0< zH_pf^Fd5Qm$aV7`dqTNQY`~e?hzl3B$7eOvkt(_$+7Te`C}I0*GYr_JM35a#0f^pW z9TY}4VYCqml~aXxBFTHvzjH-^U>{Vo06f*#XQ$)Q+5Pd^=>E-z^V8O!x53*{@O$ge ze@IlCj89Zs`M^Run+5d{iGfwNfT11w8%y@OI4 zQUpa4(r*|I>z8qG=fMt2ZDcTBbRuSQKJ&^HX33Q?d0Wo>osM8{@|IB8VcLv7ZM-5&%#!cFO)mT3B4(2iVuL56~Fm^_E2T z(_ph9){-@sl5x`!w(RBXv;{!KvOzE0?2ie6+@EO@W+?zjy~WrEXc=`#&2wB`iy-n+ zIJ^}Op`RlH{M?QJZ^s{kf5sQ%_>a5m`wzDtPFwLmZ%?j{&;PuO-n~ihuPjKdg?s>@ zO+YY^zdn+Z-)%W?9y%7NMJI;PN-V($i5#RZAJI4b5Vu6Nn1iA)KP)rD+>pX14pIr! zD|8Um#J2pF9YmKXwj30}pA)h|2hlZBB?o6*=3C*a-hWn6XamqhfT(Dqq4Ds)r@)EU z@gOs|eQ=3zC^9NNECbM3y3;{Ch?pt>ASbDfqy>9AW`hhsZmFr|^iGh#81hnmXISJ@ z&dVld)r`?x&OU2%s+XC1nQIEn1T+#zOdC;hoTiupqqkmB14fsb1G^VN=I#Um{=hzz zkdy$J{Br#J_^*@W^yK}0lANAir?qj zYgH{b8k&WJi^|+MMg43Xtg^F+gA(N0vQ7s#oDNF)W#z*TTq*?ss&WJf@rgc}V-chm z6nDZ->_F8g{}V07RT;MS!2XBf!7z z&(6kggHcPry-&8{U*6r04*vP${PtKUf4(^;)#C{O2r0frAQ!$}Wu6pDIY3{oSTe{K zg*avOzyi|T$d)OjT@FG@4%#A)*bc%kB<4A>N(ZIH#aGN{T@H$Vl9?CVqRYXJdIyn* zm?F!;seMd=tKJ>n{4HvDn2O^)yLmqkp%qm>beZn=bIS~RsP;jO!jSZU z{<>(jE-o%cTk979@Ks)AZGs4}PE>OS+T?Ta1;x$9jp;Rr+juxE&RDT_kbiCN0)V^< z%&Q;_!0#JGfVk1)`N%5(UuB(gSsVIQ6{tEs)=#6Gi*jRc38TwY31v;g4|odRyvG&s zPi_X5dx5`c9s$ZLj37@B!&d;l$~xt;d0evS(~lqWg2<+BKpF!aisi?jXdT2}r1jm} zaR-aeC*sq4fgf@WkZ7}D^tTiiUjg_k&qywv0h* zPz2aqmPh0(0AJ-P<+3pnltcF}SD^(Dtuzk^u_BRRZh*cyd3*sbJ^7&a1$QIsrDFYowPZB0wQV7y^XuU-|j8 z4sU3;z*|q};f*e8@3~(-o%Q6&;HhxkGr+{GJ%%SW5ugw|X9WniHiyo&e+)W52%R%c z_^6zr*7=aBqP*F2;vVYW2k*2G&@O_|93W;fI{q`0C^|)X^n-U4$oh9ZGhL~ zU6XXCwHIKW*S>zZ?|CS@T8u!=zHNxrPJr6>At)-)0R4Eh_E(DSOXGB;_3;z1gkTUH z1ir0(&os^d*gIn#M`0igmv<5k?Y)B^g3=YTv=RzbP*?~Q~1s5qpWmCzLupgX6%)0;`xGq_yrvJH&xJDP0U9dUmhKak;`!7l0D{ zamM47S@FecScWH5xoq>o1m%AqnqgBUBv~58F~O8`ogiD+XGMW<*N;VlGl)pr0q6J~ z2n77O{|Q0>{PW)*xO{&E9#l0IMWF%*fW0o1f13N*ucp56TWw~)ke)VmKT1_7PnT61 zC^=T%4!~U>u9uF$4QsFzM!wc~jGIQV{PF~V2QFC5d9{y zw-ksHq(otz2C#@lcG3t#p!ff|i!KDd!I}ILGrNTQ`+~2gJ-$ z21dh~^=#Z6l-oZGFg*ZJ;Ij;H3VNl~6c7Ma)y}Pw zzNt+Y45jHRc?X~_zsz_8V0-ereh1*L57#RI#Ja%v3kw1P0DcBwiuu)f0U&yY01Rm0 zTL6aaW+4Cty5w&Dg-;^D%IKhu)ii}@79%1r0$>6m(Gf+P$|3d{hGIhL0+2!+(dU5O zxIe*STF)Ew%P9-|PSRHvd+jjK z^(ZH6c(pe5tg9V>yFOg60Wf0?zVBDW{6_x#3_y899+m&4gDC*Y0U!hgB?v$?i9y?_ zIrP{SHB4gc3**T$*Acx5fOwmbFosI)+?3>o3$Kxf5l!wa0JBY4T-`-L_{LU_JGydf4;@U&|#}Etkpgj_rr zBQ2s9sCY3lbSW4ciavl38BSoGymZi_NpZDxFnG_`>eJ=Qi(P8m$SHBiKekSJmfqbd zi4lEvvgiCxFJVb0taFT>yGuMU*N?<8-!MfsV~Tcs>jZ+!un-J<@UEO`LngKk&fZdwC_@KB_JZ9@PK_5cu>bua)Otj&`nUBT0nPw>1C zfanvC^r1uWgxUq5TJ)UM-W%jW|5+v_C!;12 zpt@^?f9Ne%teQuF;VuBx%{Iup0RTI)lcA^g>k)riLAwJ$iQm!w2vDx>^?nOGM@NW9 zAIFH(Ss{>bL)YF3^6cLuz!L%BxU9vARAMV*0hH6mmqKt#c2tRgJm?0pmnEnmQj?Dw zk=i7%F?h)~f4QtQc4?7ocBqt;7 zbwjmCL!HG*A<{%7orObG-}CqH2Ho8$B_JSzG)tEVh)BmGNQr`UvouJDba#J1T54&e zyJKlYQo4V9pXWcgb7sytb7tng-p23^{+iIWUpsR4lm_%CwcO3YUvv`)CmjSQ4i?Uq zf=v3j3%;eLHy={n(Q)Vs*vz|9gyMlPo&IHv#b0%DAG7~vc>Eq50vVg_AhGbnsMGsV z13#P+Aw_s^)fF)-D>5kM2AeYAde$HZgz&KpRW{TGLs#tUmPHg?+{jgzbtb6{N5NtP z5u!Iq%*#)C-Se+@#iK7K78KwK#2fx$Z(XkwS-<@r2!b+3bJ*&78pX2IbQr5jCjRxO z=*V4hO_DK;c|V>;!9cy&^LavdFd{A={waNE=Z$MucMufL*r**)$vI>D<$Yt-(h$zK zSnSwH-gd>`sWc*Hu%-N#h#74$l22ZiSaYO^HhNBDH(d<3pdVCYpxw}zn5HQSAka}X z^Q`1DUnHR>gs>$d`vl!X@f^=$`jF47)FX@eXKYl|WB+Le)ePw4 zi&gRgzF{sCq^OihyD`SDbA2<+hn(_kZ~k5n0==tq0VV(J@jp3SOwd-=uq77+gs57C z-Nl}ZC9um{w$3HrNN!th3gm+xsY$Awae>tF-W#U!@~YmJ;gk;$KGT;> zTASBiL^Y+v@o3?`L+l{+HO=U}0q#>L$*(oypOl9t(jzC&B%1QT!0Y$L>$G~ue^raT zB@9a($mpyDoe9||gwx!8(HNnhM|dwriUcDWaW@>lhroF~t#TL(0xu>4iZT-rEOD}g zj#$<3modG;3H5Te&l3~tYsp>=XwNPS2KLD7&O|m#BnP0FLZsTH?XJPsq~4Z~SB0F_R?7-caBR*N4a}B_hZrf13=+`jmuzqz+UU5~ z*G36o-R|?E>-bBtPSfMMES#y1OHRJr!F^t|Rr!hh+ctx6r<=I*N7nvl3+?XZfB&t? z#7JXU+4-PaUm|O+W%hu3ADQQ&K)K*=?pc{&%v;!Zoo9P(L$=U!W2ufS-W;crubs3O zILF??u@j=Wm(k*guEIMxYxMyAqp0>8Q!TJBcc}p(h4eE(`R)QmYmf~;_{}AY6M!j- zB~?P5{Mi9O+JAUMuMd>lT5`Uv6kVN2))ahSKBU-@CC7m~?DjJLi`{7aAkz`Rr&k{1 zKm<@u>5n_CKv9Ne6zDX%i4J~zI-iAhb_Qc#E5^x7O#B2DZ&~)6HfS1w0fp_s@!QWH7>VwLEi2a9n;_j(4T8yMV14m}K$SwPmUuwa?Fqdmkl-B>3V(=>UbY1DBU7 z4u zUD`f^H0<_hcjkX9Jp%|IEBlxi$t4lA2b6(IM|QRLQATBbt~p-9c<8|Hy!+ELL-Wt# zds!#343D;RE+8xtMlD6k{R#88&4P|huD(F&kAWZga;MGGtrda=$T%&|-452)ic!{c zzTdmy7PK4N zCr?H8fVei^b7vS=V-Pa9Q9lLDgNm>e%Gr{72$qE*ygUl^b<5Pxy2(2{fcg3>rU5Kd zpeGGR>HeWH#L1+(qE9duk2I@UVm64Z*S`N#EJP~jDj-uQh1S4ZHh~up-foGRqBRpVR#1io)Tsd@ zi$zC`?B3T^aGChx%RMhT%;JAy*n=_!oHKYiAZKRr-{0wCbt2JuWrny~2Eum%UR{$n zoBuwu0EdS?6uq4=)tG#pzAjkzTxQC-u1KGR=UtDo6ze*LTS<)Xg#{*k3WgPS3&X_G z5UvxTbY}&+g|@6??z2nFp^3wd?utv9%Zi;pAjDA7d-z<%IZ^$kctK>>3W~VXcpO+v zdfI#69ryA0V`o=L%;m<(tTtvHxLxRXZxk#ROh^cKIwEF4Y6SoJ+5a6rC~>s%_8o*W zb^!;p$LH-vUufnqva8<{yu;uK^{?vUa;C(Cj)ggAf~z`e0I8-GLYXPhPde!}y=p&v z`&=$`B+;g5fVd0KB4{N?4<+U*E`Z*ck}2A7AvA0N4?aml*3b(-Ki<2MN=Jtrs#$z` z_rbi$j8#;Z&x0;hPFhQhc{D_{6jv>Fr{n%4IQP3Sy6`v@@o3mzhjX|PG6{zhj=jLR zGT*q*ViH;cgC7($JxFL731hZJ`S3ng#~2!sSV0I`&4hIah1|F&`?^7Dz^>jkpFLSd zTh=P+2rI^w&)@UBR>h-L0!x%VquL|7FVoXpxr>v%{qR&r$N2b5uKuD1yc;S`SU?Tv zn^MfvG${Ddg1MFnS4))^1x0K$pSFxWc<=b?sOr$76xQd9T+>E1d1GzuvV1*0>UGT) z8Ndv$s>xtMmagrz4BqW7rELs!r#bKOnS8LNz$P3=%&(?!MCp$oxDza9r2=DmHWwCH z7mj=Wa!F^R`*M!(+b_?~&cwwSHW5&Go!HzRT_1t20$NY4-#)|ax|$fI{r=^Tu?mF# zd#!{nA$BIGiblUF(Hp6caB0(J^Aht6pJ%783OpZN(uf@_F+MNZ0Lxq_Z!H z#Eh%w+}&uwT>uGQMF{Jkn0gT!LJ850V}tSNsMQ!uou@WRR_@+mQ3ApsrU~~ zj%T;HBX(5YCP=9fSPTHCCvk{l3MAd_UVG2fZ3-+2YR>Txt2oRFRBsXPQKyw;ggh<* z4nUy`)}NGp3Tt1ad*WYW5UYvsWjatT1!S9Qb1!kJtGVEi#VPw`j@1I$YpC=u zL=Npe$~n{6NUwbFbZLH|k57fO!1 z4LnyP@ehA(kzqyS5Q4%BM^@|B*fQTt^ zP?nW#VH~*1XUy)kK}f^hd+@-hacOLx*aCjiS zh~-3)Pa{_m`Og#B$)iE_ycfJJ;rH)^drZU{X#@hkl{j^PC?f)D-YJ5s{yNJ@>hHpo z5wL=?OB_}Bcm3-KSqBM2ZdgZsz`T9-o9j@Ej#G1tzJ6Dvcsl+4o9AJIiw)ERUbGKIs(H6B{U&Fp)8v1U%_DVnhLi}&4Msc#>@RlDZQA*nvX%n>TQ8zMU@L*I}N}H`x zvX!zWMtpAGqlYOq`C+5y|wlRt$Tn?SMq_mt$nJG5z90lR@93;m4nM8#q zr@vE-S@^tYDMvJL!%CrCSo0E8N|R&4;)uweD9-mi{_-%it6Y2O>1>DgEA^${^I)k- z^C1Z4$R5m_+q_J#A^;-Ci8_ebd-0l0mt<4A??~$o7x|86q*~=I{pE%Rn9#wUYMMVm zbB&W}&!e>?sUx-NpJ{*)vn&Z6?dhO)&vm_$97s1vV9Kg`TUfa;R-T8Z{P=_})5d5< zP9=C-i$MdOyyQ|Zcl76vTm9VG(qzj23i&tYf2rOZ@lC!&-i~3k1wmBvlWce7uP@(A z+Xk_X4e>pMy>;?2<*k1KZn)_JAZD7#y#D zKvYY;c|~uE_E!f~vzfC~)?snJD!qw*2KVk5lxX9byEDukng2NxF^~{yQk<}U@;a4J zlP9}v=ovP9+dWn;gM9r0sHq#g;xIFMJu`Zt_g*|GTkZ{%Q<8Km4`1U(79_O=1+C#_~_cAt9!7gxyMrzE4 z7WJn3Y5g8*1bttUI(|zTGvTKc{Gc02`tR;lcQJsW2e*`4 zZz4rWKDLn<@3;mW)h>3)VLL=yHE}}?_cegbMwVygGt1)0L39W4GH(b_JaDV&U_ z<6D_ucX`U+v5|mn0m@&`j}_gfI*iHv>Gqcoppl1*f_4U7=AxxvBP5jIA! zB#!LHN7PQ5A+lC(&iY!QUjOnHkuwS2Z}{D+A)rvKu#o2X$WkRrtoxuDfp0NJ!I~+u zz>KPE46ii>B|l&J+_@a#2dWVK7r+{@OO`j?=|hR=U*swYPzG{ii+4%0u@tQMVsKCA z{*AY4MgG@YEC4N(`n=A+ZfDNgUo66%NegL7&hRyclwy;{mtn)={RVB5dqxC^QltOe zqem#qG*`edF9%e-0U4WBd(}H~gh4MrqkE{2m%lgE2jnDlB_5C=?0Jp_H@rf#K6K}k zGJ0TZnA$c{0N~%M7=}Tflix_NC@AbH&yTM6LB%35MNWRc4bN(8)~m|xOkKUX_nNME zu-taWGtmWZO73ox2ypcF?||+vvj=7xOUbg<>RT=}+^>j>uTvGM;+ycYXRh&$LBw9E znlAzD6}X7LpoNyU1kh=|qW>mV8y}&MdE?%FEmk$!8PgZ%>SqchWZ8aNbtTD}`~!c) z)=np$ri{U-T%Y0@0tqom2@&v_veSnvQW$Ce%<<( zbW52%3<<1f_=Oy(;_kn6VCKb>g2S+gtDCUqrklWg<^Q>|#Hi~b>f-6Sk<>ALtRxS6fX9$NLXK)B$+Cr@vbi&>UF&twr&T*ZWQha&yc`LPZZ+nm=qnXr--E! z4RJGQkIf~-Em&H&i9DAC0yM(8q+7rO2W(sdJo%9^<=^#4=}s}+HMWPx1VNY_TeZap zr$Z3JfsrecL2zO~93#Rf@9n8=9!@#&2QiJ*Wec@ezzZfaBQ)z-yS;cuV?D5{KgZ*? zHbUN$7n@D$>*1)aP%9tm&AI=yDrCVH$x6~P_YDa2OiuSBn)x31H&VH?%y9M~@ut$f z@&XhZ^9Orvpbwy;Zyqv(>5>cc)>x>@;{6&m4$1*XY>7uP?1v*-ZLlaYR&fOwYM~=t zCp=ZsuMPhD&t-x+nS*ZJ&71~B{OoW$h(2`qTMp*yapG0T60S59m5x33qzyY7f0|{P zLgc+u@C@F)5{GX5#P*|a7^S00{O|$ofk5HI!NA3GWvR+l6*;3(=Mjw3|7_!D1f$sv zIloifGXDcy$$>R6Ur1E&S(Epoxy3-7Bo}6Z^g@!UX!FM#CsufSqI`&!FGryPmP09+ zdnIyuB@f%{o!9zJ!>uo}Z30UhT<`ge;{p@OirYB%XA-U(@-kWVJk2ZYB#|+N6ak?N zT0CsRjd1;TszX~~^fB}cPZN&X-lZk4&r*l&&1Ijl&@p+0&^ zz9Bm6`#S-B@Rj=x(7t}NN_D{gzT-azEbtHCgOZdXzy7QsMEL#3qdUFK;9NdqT5(~H z@u);xO;ST{Xt#7pKxjNBs6*QRox3#?uQOo|$Cq~$uY|8LXr10kk4d{?zyi`tkm_sR z8-g&miO=N~(qE$6tdCmRg+vB!H=b^w;Ve&b-4b&`s12q{g4y}J3rGqYb2AxinXc%D zAshER5);QP_7C%Xo7-wZpKzz@JrH9^qdban2v8pwIF#X+rPMer#G+H@8Fpn)wT0mzf==3qwm=M@QTtR z(7rg25B9vu1V5O$pC8*kIXZW3*i>NH;?wHcgbVf7yta>Xx?+ZTm7vWYnDK%Pi_(=2 z)P%o#rATG|9!uovYAqg85qVFF$d%kvNl{?$)i{tjLWS{^N%=irF>GW}toBF@>e5y&&p)_=~r{SVL}}=!tONYZ8GXI7b$`ev~wfoh8@BwP3vj;a4i40ePM}gKY;D zUX-8Wty0zhO*PqibC96CBj-1M;;+Y{WFVTDOc-A)Zr2ft#w*?r`%hxK%My|``2T4& z6=47Fw0ak-`Inl$iZi31%^H1htV$YpPfAQu&`-c{o(6{KT3a^rIX`(NV)DT}STtqP zPWB}4g&tn;X9Sl9LWi9mQBzJk#CGk3fgr&^s!`+pvmk-D(p0U&R(`02`f`4)5rzL9 zuTiX^7^Q#MUh(A3l9;}EJV*@N{h2DNupF0FY9J7T&Q})?fu5WDiaHx3tGLWJ$8xZQ z=>?5@vYuL>Oz#FiO>TC}P){}6e8A*R>pr;Z^G1v$>L3g@I8+*HJjc(rqdVq9%C5B& zR!2_9`wNic*{inT^2^RzkDgD+ukEvcFI`@btPAthi)9^-(sC*MtysA~%Dh`UojF`4 z$a;89^A(V)qU`hi$B=jOI|E$%ggay;s;u-EuPUgV)(0(0iOj0Jrg{&(;_i&j!KODQ zUc{cKT>G&R(J3Q}nWDh)Kpa5ol|wmA6qe;n9ERo#L3nCA;@R`rr#Xp69ezpTRTxEI zY_OcqFinm4C!k!PriQ2^rtOm_UJ7p(n_0OoY53aS8rt*i`aCQ3ZhX0+SzX+_NfW5w zm{trqr^#vova$U5$V4K_gkr$>xH&<5S453sXli4huC$(W78pq?r+y=2mG?44;`G@0 zT@iin8M%GKZr4b%^YKL1RxPvN967szy7wW=pL6p z>kiF-cdOaPS~(kS#F|ghaGoPF?0{ku8*ZJU&e@BW{f5cQ)rBQRo6 zu_5NxIQN$9Z{>RXNurg+b;iSWt6%OGJj8AM?SHx9=nKVHDn(V)kQxUlHTbJsy0juE z0W~aR=~|Wj9ra$MSe6L!Q&CxVgPm}_LE}z9m(bZyac%Di(h~nR_>X=1H=3dS+eQD7 z47E7?_`HCBc*(b^f_seKPb1O6Ik@Y`4I_Z9gwOfiefX!9ur_=Emx5cyb?+z98f&fQ zf1J7JfQW z;q_vhKC$bgw3hKhN0C5WvCT%Bd*TPXz(ZjZ_Q#1cGe@$RDyWn4F+QM2TL{nmFPPM1 zmJAVXoMGFLSnUjClQP=qIDUlGQfli{LazQABd~%sSP|&BAm%TAJSruP4v~?+Q>53K za*AX(beMuEiq!1oq)S4tzI?sgmlX2Zu)Uzb99NSCKD(I6&15<8(HD!Y()xfy7c0<~T-El@S zi}>b~u`aI^iH)p+M$iEp@6mwY{@&ZS8;QH;P?Vpg`fI=t7m-@`oJQXA+?b;{r4@Y03|Nt&ZZMC?^ZnK%g9xA5q6)aaBQb+-$hE@X+-8Z7E-rob<#ex@nTSN z_-h;kUb>p2l39m-W?S>p&%l3HF%dE)5BCNls#2D_8$(Vy zaevb$2jH@cqn71b-HuTmX^#lspy?hn$;H*eC&9cQXUDyNQuZoK4;#!S+jNdTOxxEu zJE0*@LH$SA=({SQ^pLk&M2pzL92*{b_(+dzB^B(|-8j3{LjOo;x>w4h`PhpOzNw`T zTF+#_^lOy@v9;~tyQ7$>_v~!hj(#=e=%<SsG`;;r?_HW0V4&l z%Gl2wA&5;RG{tXP2O70_pa{!K&qz6k(zf;n#_W>Op_3z|EibVV^M-IapIeQP&%$xq z-|Si#hlBybHgFTrEvYmEEDQ&!pcl@f{X6mhQ3DyejB{A@$8d1fij4_~ z&yzZWXFFeW;z$%#ND2xUP>#aXBpWs8!Zh5*1Afg)V~gkD6Ucc_6Cw-Br_+C0L=@uY zCX{5~&`iMTZVYIqS3Nq`<$Qm_jXn8^Zq6M^ayGK2xtp^WJ0gK#k~e`;{NSo~%)7ia zY~c-%+eff54ZVdmx62y_aiFcnm(~!(_Y6>;VinT8e>2=>?;qeXlIle~pk=R-)Q7}S zgH%B!j;Y|^^PO1tKkUWP12>$!rikxf0(9U!#?br@ z%mcy^i=RN*s5U3jBj;B}`;Y59Off@Gao~*`5Ha$0q)Dl&N7^ZXUn=(Fc%J?EWk1m{QQ#C#hyx zIpmWTmG2V?k&&;RK>YN2=?M^8D;(0v*O9`0l3;ZwI$)V$nUmD;saC7>vM5R#p zoyWD3oJ$A8Cnh#zOgSx8?|T9o@HgU(?|n((Et$|K%u`Oggmvlz&@w2D41IEg$&mCi zsoE)EYV_Tl9)j!r;K+KDT>KD<;Y9@aBkv@S_m@25YZRm~q>Mp^|7auV*GN@`w*mGeFUO5cY?fEyik4gd6a>&_Jh=z{T@qc3}zNSjVL!%-9CiwJA zbnVr6gb?UIDo`uVKEZ1`H&ibWE&Bh1OCzJBhGJM&fC_JJw=K#E+j5uh>tS~r5i&^C z3v*}ca6Pua=G|F0AMIq;?WakTdp9voxr(v)2i8djvTTXB0t7KzRX5mD7?Kx?1Q`%o zo*1+Y$dhMvc+Q4~Q~IIlqEGfJvAB(KBR#mDZ0%*lyuiWd`N*1a5R;xQEtpK`=qgA= z#Y@P+s*?iZk$FADT1{6lto&j@*-cE|7nAFThJCF8^zK|AyhL6-8Aa`0I7k1YmV zz|{y&oRc+(C-tLh{UMwmUW)IVV<}k0J;kT-Vm~W5&=|6Q~mk;D8Y(hrt7DF60MM|e+nJQ+w|WYzu^P@4SO$zc8_Hm z`XD~*eiOhdhIJ?J`x*bkmZ(Qj;~#MZ9ys$5=Z@CV@Aipxe(@olVV@TDJ%I5P^)P_; z_fohov5IBTxCO$j=H57*UqPFf zMCE<|CWK0i^9v{JysGYsIA zIYt$^cr@(xvmI{_hpepNb`ypdQ zW56yHz^|5Ff;S?+P=K4jaP_hdB)8sBP;Toh9*pex33zJkLN z>9eY@7297I7*PrF{dbO!(ozOQ78=~hHIE%D0B~z~+VCA|CP5J2q>_jv^^4PVC;Edv1g*^(l(rkayMGQh==G=4%41C}exQBg;C_D) zUMhvX=!ZCwquS#zjRYSe-6*aw{E;F+GKpWHANx_urF#O7gqSlwwy+ANd2KVT|1B*h zU~EU15Ke&n8&bE?rIrVC^veo_7^+0I{gDAvn`M)vg2_U2!omOY3T2yF)X^D9KBHM# zLBXF96wsUcQeWCT4gK?-$}=wPh3ou>U-qTWkbhdf5qhP)5iTx4o(J6+J{z5Y;rxQJ z-u%jn51&IuoyMNXKS|5NhtSmKTta7=3H{U0+lEzi6 zozQgs57m?AsUcAI?&MtAF!DVANBj&aDvj9qWBi|~8lwxW=avEjY*RY-f(hk+rgGc8 z-|niPPL?3+X;D3(tFGlE?WEP4^RBJfpa$)(bFnNRA(+EsbAzOzZ;R;9(`7a1jXp)k zYfv(S-)2X|vT?d2IjSrc%k2di6`zO52mO*g0SULc5}rX24M)rL)Hk~;6iHZ!tpI(? zqsDpLndr;UUFxs{-A4yw3QETGScQ=16kTa|$MHCrs4nZ7mr-fKH2eICRCV7v3PgcG zJq^OdzfK>K@uje+ufHB{hLQvM$W^;MNb^{{-QV4d|*$G+mJ1SM2LN^ zM~0hq?2S30NBpr?uZ5y#?0mJcjmh7N(`tG##c+Z73c&$y0JT{$ZXhe$(EByA2e|UJ zxwL7mU$srMiPS&cg1687p%qwZy2=_KMVomsc~!lB1L8ryU@?Qv=z-c;pgVkDze!W3 zru9P&u0lpj=u6K*-k+E;1;-dJa$wUZ34)(eF^KM~Y0dha?%#>!J!prdFpfZnNT5w8 z8~;Vm%t-p2S$S@aOk{-6grCC1^>3XmpN17qn;9gXfTiRofIP+1X43#&Jq-6mGeZ>! z&nP3%p>&vZxXU5^`y5du=kI|Hf^cx=Q0lsQUkBiLE)v_|{{0Wr_8h-=L>p5f$yQ4iu$%8NOy8Totaa>9oY17^4+j{W`NU7jM2>$cFWk6Vk+GP>CUbXoFu0k3N~1K0B%O4M-l z_Se;4f*vA(u+Lm)9fh^ntEv^B>s&{_Z;WOnT1+8VZKNkt=FXhi%!Qe%?m#OlDXW(I zIYfl`@a3n_p#V*SmfwuD=k_XZ-N4ffKdxNg-{-JNls?RrmBNHWT*CyUxsf|xIIAG= zOpfE3`+~^xf`WpIx{O$i&wp{a#6EKokSEp~iVGX+>6Qc&Bt1L>qAbx+w_kC`)X~2| zS#XJ0X(p9VKy#IXgV#X+aPa+_160~rq!Q~9>Q}cQZO;V!s|m&vPQLJqEqYVbPmW|o zll2NEa*@Kb8P7Om&0l#T7v^4E6(Xcoic9@HgKW}7I(aH9&8+dHQ+3mNENk%Fy>(Ct zjV$+p0QOyr5P$`_{K9GI^lSYRbn;%Bt40)CldApaaFKj5Cs^q`fT29Ku(>8*xcNF-#g= zohTgt!F_&d6>i4JR7O~D#o1f;l89Z`Po;YVqnqTV?d?1_5NQTTeHi0?CYEN{%DBre z(PQc4uzMgUfl_N3R5pr=z=*9nKuN)QJN>98Zrk@ZZGhx@14%4F6F9R*tA zbDCc_USP@1i;~z7t|H|&m;6)8&?%<3JQ$1@tXHV#0OJqSSkd&e;E%~^>$Rm1PFbbC ze`xPn$1!e79;#BKv|eC=%|P2w#^xLy&Z0N#VkwvzSZ?$Fj7O_sxs0y>`vElRr8i0P zFWd;t8&^a*nSoW2rO!#DNqHM1#v5Cwj}ceX<>&#@X6*gS0bcy73Lsm4bVYT0)rvND z4xBE{#M%(!b$^JOA0#+wbKj4o@hOWy{&KpXp4yt9I7?CtN}EByvni&&_p2ij0?$SI zwam;mA%F0oXQ1uwfSO%}Tixa-;D=uMfKx7y{hFnJf7i!;G4mpx6Se#8F}pv zX>7Z8cbfM(iPp)vzR%@-4|A!j7$vRwuC)WTcZ57bNOHPN&NA_fcnz4(C`Q2PX)hV( zfa2zS0z3*IHl-RzEjhq=6~OTgjR3hM;Q`mwHeRVfZW1?YRN;sv} zPd;D`UjtmsS+>A~pF+Ah2uQK7K zAVh5R313j<>;0VyaD0sDI!GKWJK^CgM1QAODapN^&6TCtVel=%#{l zNI@W9I}Z0%CvK(16vIT9zC1?`wntvRS)zFIk5~Uox3lD|+Sw;wdDB3|=dqXDBGLgq zC*Ur|$nUYehmaJp0u={k$B%b+js-OU%TsgA;NxO8Ll}&DqSWMZ2e!f0Tyd{J1*n9M z8)LRBu|>hF00k5YR#G6D^&p5ae0=kx5fhj7rxQ{z#aCXDae!Sxp_EB0yiP`N+9a*? z;UclLnB(TOospi*l#P!|<`RPV@g3%I;|{U!1l;BmyHa1_@PSd`D;O8dfPheA72s9m zV#vb`5E$q&Gp3U3+7T2zQUwHzNNZdG34&<*jE3EqKTkK8dxnXuE3gEW`zF-}$fcm~ ztV=$p7H;JbHu#ko7BH(vK2U!uh_pff{bzGXU-<>$*CLVE{|HI9E`WbwDfUf$fHEL{ zg7D_d4#{SKH~}r@IJqlc41kNP((FsA$Ef^lz-#`gRfH*pi!>(OO$-;Ytu--cs>sVj zeYy?7P7iCONss`3s3SOnpAQSYTYV?fw`aWesQ-MPjR4FYOrkMu51nl=nvZn7?ix=&l&u{RPZ`j` z!sh@?;slmJ4nZ?LYr<3PYUEYVn*^EPg}w39nu&97f+0*;beLj{T)(X4APdiNnb>Zg z`5TsR3EaOax%u4AT&2akAV9bc-f{7(unLz_w8&Q0jRYE|s*_S!L%5c2cqU%D4+?0U&81b^qB^v_{dqcs{FNp=SBtJnC0L2VrfUjMv)pes(S%$G1Ys z-KXz`2;w=sMSw^{d*gsor#S9O&7Hp`zX#2V9}>(AJhNf`;N7fknxelZqKdvm!w~bA zflobbWdz$hYNt(b$fU6a?_}?;ELduh)>?&7*w&#U6rgpzcuNFQvQekUp81TAnEi|~ z{7lUZ6t@H-Bjg&4*3s@s%JO;Q(linMTH@dX%WCJoXDZ^4;YoU$#js6E zzBK%ycI|=6%lOV*zhKWU=un@}I}Lm1_f9n1I3ZjPx#uJZMlBn6Ay+R+e1EaWVW2#* zrBd)dVscwi55=C6hGQa)G(G<*wbn06CNL676Bc&+bHz_>0mObrl2-V@)XU+Yw1|uU znnaNU_;G7})&pQAs#Sh16Do?kQkwi*mdnr@$1-*SP?^z$XcDm}05{1Kqna+S9 z+gMeGvc%alpGn6MNWQ%AiiaRXj@cK%tIY?%93I3Bgbh5qA)5lX_%u?G5+iOywtSRs zvrnfVcs>CfOuEiG6#(S=fNLc*6?pe;-8?#pS z0PCpE2o0ku z-Yqhnr74ryXP)0x)W~x1@djoGcojCY_>5Zf^|lkyT;nh-VcG?*^>$8NDD}+yzGEBe z-MbH28`$^}(thVMx;xpgQs%P}EwFoe=>6^1f&vkbE%l6XF;6Ul6c>R<0ea2?xQh8@ zmq!zJqmf@RN=XYfj{PB579Qg&-z3deF~A3S#?ef2GW4utWJ60b%kVp&^FAv3{@E+p zDF_O^D9u%Re>?v-9#y4}EDs&cWaPaPm+EVGt$|d1d=1~ys@GxQm9V$$B1^(>BSy!X z%`j@LJ-@YUzaKMWLtWnYGUy?WXaJZFpf=CJ2#Wcr7UcQK_di1=pzWtGqd*NrSC!VS z==&y!)0c822>|IhQF5oW`{~jIDt9x>@ij(!j3!w9CndJB(r96=X0+DPZWkJAQINi#;`N39k>??!oo#U|E_0m(a`|Wi|xfSZ};M z-%?%o>t^zoyfi;A=BfANagl`2U(i)%1JFuf3(%h5y$A5gM#1F`)ICrXQBKAKHV|368IZzHJIWsi{?k>@UJTrYD+H=osSlysqh_7M?9Vav`fpKF&q1& zaRgwE**l;qbRjKBDf@|@Kvij;B~Eql9dR+*@s)+ z$vX7PEaIgP${%%h-gM`M@ijjOFdszsK<#9pV+DgJ^<60tTzzy4#~+s%bjSgO6tXYF zX6JM%K#lLpvG#?Fo`-aQJO{{RkSrPooL1Z8@-#yq&uU$A9e{}XoOP$a(9Y#5cJH{r za{|+FgJ0ss)?1}8+mTSaaFfwWDs&}5%0-;J{Xd}H?$eQOZ168kB^Hs#k$H=l==WPP zkDv$9XV-}sUVmQz{n~^(4>p9|xyY`kI*-n4dxRJka<*tCZSPM?X6hk5P?NUkE>F&G z+R?osGlup$6;Aa{#sGWH@A+?waI<*)ftX`8zFjg`w5BqG{)DC<6)Jcy;>eCGt-B$7 zrR~45Zl2}(vK>ANEe^jGKVm2K3y~%)3;m9hj*swRB?PDm^9Vx&vXa6IhxX44IYVPG zeCY&%)frddJ30PsbAAs+{!JFqWYs9Vg4mbAY_U%9>Y2{}_&)$* z*vRUVwvx=Yl7fbX!bX1gsD%%q2Z2hi+NOAfZ9zDOJ&ObMp+H5gDf#uI)Sq*&kHB%7>eRY|0@e9vJi=EfMuzY zp}Xte5Z^iTK>3{>XW|-wJb5HdaNEnSOswaA#=&g=jS1_qg%CTN{nJ_=6~1b>QvHaxpik&Eey2SHdlvVU?#;_n2 z-r}*SJRwvnxv6V47BoIOojipXnkjUhvJk7G=H=8^)(P0;-avzj&__n7^aPvC2Iv_# zw#U(Iw;?KpOci-bF2gwa6^oA(^9zBzXyEE!Ica;pFR)`)2fxp0;xAjC&`WLSD;3q% z1($`rzqY`avqHoU+@nl#(y1-gK#$#29sfv^g0E-@bFS0I3jvajzF}xXdujZT-49Tx zDhDps{vOKElHW4nTbndZ4wDJb@1gGo*G*@A=mpfAY^V}$S!P{U7L;&Sxt7PE}-d^C$2}z-Ln&j)D9O z!l|M|XydO<0QT?(-9tql9ctCb?XR?7GDz!yX|JE*R9HS^OtwuygCM@TUkwiG{E0vE z(izb^-?OciJQ&OQmi82fa8b(ndQ6Pay8kG*3Wsh)HW+;8rx07iMHG2)#GUYXJezUq z%?k2MaO{^|;{?+{tX0EUZ)%P> zC7MBShf+j3?nrum`v{>gXH`Lm2Hy6x7Qg~!{&+&r2P5bR<4HpVBJ7qP7fyJ=*#v$O z^6q2kQtUdxCOUvV^H~o7cWG#AHRAn}z&t-U$D!l$Z@wF#opZTZ-9m#uGiB9UrYIL` zDs8|;NJnc=%yfp^_|-~?1$~j$3=Y4vCVQdZhhhD0Fp*f+n47N4|AF)MdG(DY@t;vT zk8ge}y9}XVvFZwYsPqUX(vTGF-eDuN0Tu)5RgvP0(j&6}4$x5;M@FvyOXmIy{zML^ zMd15VU?IfLRZLb7Ee+VFZR&KnzY3~sW!zb%eZfOtTO$H}|A1VE@9oIB#DZL|yA*^a$n z@R|`Y{#Uo4R$#coAy|6u;q2I)4#P)yiy8DdJp2RO$IwzTQ;u=BK9&gq*^J-0MZfJm z#c`&ZE}A>-<4e+6>b8;As&Vj#l_ zp;R2++SL1YwM+AAyNGxyKF)SR{J^Z01LxSiB5S+Rd(o<#AqxDNV{IxkwE9u5d7 z&$s+e6MwSg&KN9JbQYoG|!F zEgQxjY@BTaSU-l@QW*liM2vHH(=L1EV%kF`biwCpu6CK3i+|hY{Md$SO{2w_*f8ns zXSsGSiK{{k7!CU79Ok{pQ~P1uQ z+=d;X4S3zbjHo#NQl-Q^r1|$dWS-`Blduu)Ib*BvCtbTgWF-G`H@oaE%Msxd?VF9$ z8%x7S4;#!qR(n3NVHCVyamrMJv@ zOm|Pw3N2rOfS<;+_4BJLADHS~$&|Kfr?FEvzg6f$)%#au1c(UEX?u-ksnUZ|V@87<~9DRv2xVUE#16N@B~Gov367k=ShI+sXV#X$$aIoh?q&F%Pn z1Ldk~l4+nDA4dPdLyQzQ%t!5$uu zWN{d24QjCXF2rN_ry&B=EgJnid4XeFz-{i*?=Y!*@+MH!6W~`Uk|mFGm|Dw{{%-KZ zX-h8t9}U+0rRqmJLX05|azv#*gQL1bK_7s11>*+!rnLg^pKWS52 zq1}f3q3yj2gpIQesf6L}hLm`d811gP4ne=NLi{i#DSaf?7G^bd&N02yB#oW{FO_Bc zm!K-XRmYs3w%+cT?Mg*l95)Fr2kfQWaRiPmSA!W(oU=b~8;G91dL4D)1IUdM^a)OY zT(w2XOTPY0j}~iGN@Xl5xdhcbN7!m?HtR6~DWE~Zf(?J!63!;6*RwsW77t|%lZDha zdmt=F#3LoUuwaZCI9Y*n@kPO$XET z;=I>>%Lqpqr-eTH5D=1X472`Xlh$Pa4AU3jT^4VVUe}QJCB3dj_WX2wV;Ya2Y8aZ` z?;s6K+Pt|M!-=v0V*%gRp|GFQ*uT74HGEnq)xg&PD+L<-SzbvMpu{hPsFQw`xl%YQZHH8h^-;xpDY@+wtL z=p7u2(0n<)&x>mfTz{Qd51oYJZgXSO1Jw}%5Zrn-mL9g+NHLkwKk)cNF~qH+p6ThP ziR|4gRljRw(pWKmx}0fhyP6FGG!jHHQLJbNoo&dqw}k@V!zyyZDe2$32~gJov(J{%1jb^htE;Hs-;+YqAnjwEq?l)}<=>R7)G%Fh^Tc*4@O#ZSKdq?26C@*XD z&@)`m-ik22D^M~0awc_q(o2w(o=nWG!&%}!#m@Le*W#CZJaWs{{54}V6hyk@;aH0~ z468tKeohC>D320WcIgdIWxcn10cna}Xs-q4SJOz8hQ(}cI;qrXMr@GtDUBH>?4x#r z(R7R@Zqx2cddcR7$<-He`-$HVN^2~-<7l|$o$H8+f@y)p$KAw(IF5o7PtgY3Wy{%H zXvzQ+f91-dVjGK6 z=*7D)Szw^dmhRJ5m&e~+U0gTXtmZt*CQXY+s`lrdNWKzlrT!LRw4>JX$?zCu-p2ib zfMIH~>_JxY&GfDvxMS_9JSZML!U-5(KJmW!}qhta55^Vzw~$ z4y{&nB&z-;(`QnQY+awJv1iV(A+7h`|G6K!?Ap__Jh_~DsX#ONq8hIMP>6>Ej6o+I zXkNU&j|!xY1$%!Iv&IP8>r}*w0ZAb#gA5=J1xgNFYlAxO?F~Ag20t~PX7bM;e%&4& zy0zu#jax%Kp*wPsB@GCeRJrH8mCHhc5;T#QHj)R{mLTRsqj0?#bW zg4Dc*p1<~-a&dq0e0wV~#YDFJMc)3=$teQp#Ko2k^4Q0`NH8wZt8<)@NTn5pBSE8= z{6p_3^Saw+qh_6gO9My_K2>DI<690SHXT<+GG9P0r*3{D-S1q@!uH&`bJ21(nOWFl z%yf(X6~rIrpDe$kbt{doVdC{BvDOn&idQ7)EK#qy4&?RPm{L)p*(@N6F7nq`SXi#BW}_592m5iv z$S)(?Dsp$U&!=1?~@P+VF0{*Y_;H?G_%q>%eV z&h2b|=XU+Wuf~_c@?@xN9iKJR)-2Rq}kT*M!G29Ay%`Yy!8%4v3 zL{fxsQKGR;uAGE60ES7>j}8$Ax?mdxB%`JUq@Dl~Oe%D_c}6AM_c{T4mraWR6W1T0 zDfI+cgSl^?&b~{D$Sq3qg8Z_#rSSEjX-TYZ1N&j72S2y*l0hMw2jW_1u8BY`A z@mp6Ci}E|fm$MXEtol)^JP!3gP5h@)9mr9NghAiSrlhm zoVbw<2gVx6*?aXJy>9Zb_Dugy>CA-dA)zesZNjlykuiAD0MpI8XQN>B?siX`vx2$l z+4u$o>)<86vyY`&Xx2i{06Jo*Ub60+1i^Zt$)A)u0yzoVxYmyBi#kH9sfs2A=2($H zq%pWZ9tR~FVfjQ{!?XVZMX4nX2Zn_lGxDjpZl1&S%E$|^x>`>`&NnLETk{~mZjRtr z)gZ^b5{g3Y!#e!O)l>@se%>?D+CyjQ@#f1gM zyj9VXY(|t?7b4j>-+((84gxegrx_%URV~jjI$IKy#B&rGf=~+njoyl-JDGU#lZCDJ zxXX0r!fj@E8?KJ!#)u*2=vJQ4%bpl+fX#xf8XBhke*`ob{_HH2Rh7ud#PJWekCEMw zxp@=;ogQS_1X2{WZ<@9}wakzT?$*3ZgJrFe0Mn6E>M|-zZ z5T)jn3{Ai9v`q5}Az3OZPVLdj*RR|D$qig5$Wc=6I-qr?K(yup!_sE2fyb z^WdQ3+-)!NZ_>Gj;KbAC&xPN_-ut?$!ojdcGY@Btm}0n)`c}zo|`s|UgySq#?8hpw4f&PQw4$hWJ0}d z)xW+7JBCV>|G)@SK6VVb!7G7Zx^p&>F5C^TLP}D;DdG50>oa&5k1tSi!XzR@dtkc@ z`%w&{*yU)*>m)kIWC`z1KaF@3tRToDI8lAL*!mV%;vh0Na~Yr#xc_niDqaR*1$%#F zfEl@J*ML+nCQSCwyeCTREy?Kda8cFQJR#RfcKxqY)e~)!uQc7}1fKCkEn5Nx*@0#~ zKk<64A2`c9|5kV~6DykC^gmXi;_5>?!f<4{goK(r;0RvYaf>#re6u9uurp9qd{ktt zz(8qBbv2tU4&6zEDa7|2R5h;HX-M+k{BGM6y| zBjOABvARf2W=N_@up$NC4Q+KX0>LtaWlX9xs!{+v>rllM)=Vcstr~`60nOxPU>@&) z74G!OPug|&-G#o>y9-@k4e$x}*oHhtKhf8LRcHtcgzRRCWz-gSi0a!$Lq08VGfWs&;UJ4V5kiON=oFu~D{Bp0a zH3Rf=t%@2!Tm5U%*s%{ z>*66*kn=6X)6%1dQzaPAIKK4GNHF#CY}xnngszqRSUs_O4XIko9DgjS;0(T}aNLbg zi4nq0jAGJ4Xe1!ExWC@G)~2gvwx(G_Dkm&H7#;X)IZmJ)c?{$DDcZUsUXfYm5 zn*uWO&d27Gx=reQ&lbr{d`7&hkx%Lj@bzZe^X&PdQUFzOQ*{s30#r=7Z8@F{ zST#po3sE`zb;Sn19ybF?oZ&$S#Vo(JY#Qlq`lLiCq>Qa1C;~ySCnyy1YUGe5NU2Ew z?e#NESbKk2{aJ}Q#?<>&?uPtb|FGTMlPgf<-IL7gPq2335&?9PI<2~(qdd?ZPRZ&K z%?L)wQO-@CT#FQ@e5%2Y7uXW@Rl8ALh50RrqZTFm=h3?KQ(5xW4Y*2dP5AqxpK7SV z*N-r7LpDgZ@=2n}d{@8#c4sSAaT-pD%9hf7=a2n1=!@N*Otv5z!pUyQeR;IkjO=#6 zMYGB_;T5<7I+AAaqXb%gvBaB$ykB-f3K@O+r8cf#CQG5?x>yVsD==Q}<7c`~raHxJIl&bW$ z%kKZm_*%Z(y|v49W+E4gBx2tw<(Gpn%P%V9q+YhPZ6#24*cIkI6b&p*S9#h3gC0fi z^vw1Ef5scOcWH;pJiBq0 zw3hEi=U{pGn>yV8=+2!$3#eipiJ6&=WwO=5kKm%iq|VdOA_#LQc}h}-bOBv2>k0CG zLwa{;Q*v@0ZhN@@7^Yeez4*H8xre2d-$E-rAW#ZNWx7ozdeZsf_9W5;ASxzTUY_5?Np zA8GcB2t?|Z)9_j$2Q{+;1jz%vF}*PfyVC3jEv&CROg--=DDi?|phNw+unei^ zTy|NeDT-T3CyUXE#Q`!d$0y&H>x2lO20BWb@Mq`SGr{LQ>1e_wP|osdRV5K^&#G^-gE=*>n$2UdBvIW4jS2z;lx@ z4n-1A{BA_FA4wPQMmaix{zg0oUt_ZJvad6rjro~Jf|?<1&(arXp}~y(S~^h$w4maB zIy?SONT&Y&mp%=|EAs=bC*-o^w(e3zME~@@%kpmpr3t%o1vKJ~ja7FHbfTu-YKK=T zRY+=ol@w|IgdCCiuG-9an8lNU@rus5`!YXB9A={{Sy5%JyWOy0urg6)aOOPYykTXb zJL%nY{>W^mgt3n0CcK}j4nq9bB zJp9Dg5gz^5txA^X2X&KgbhZrTXd)T@bgY>&p_uNzv!x%=$m? zz81HBp0<&e5_p3WYqNSVw{YAu+!HkuR++#euADbDB-9QPwy4ilW_kMJyzT-?rM{GU zP~1Bph5RP$?V}pqsp4!}O@Be$pX*Z#$ zE8+g?APH_c{Q`gb+J+0juOWBZopzTw$mT7Y-c-&QSj`CWah#IO#u51*u7xza+l?nG zOX)y7CGu2dd{7-hyWi6(eCh65XHjgj;ZZ-d^0S~a`$hJEqv;E4Mofdgc<#;Qu^+wP z0aW?3!&KJvoKDkcY6V`@ZdRo&{Y(QSf)_0pt&1yj~%l8{F96;4`Y+AEBe7z zdSD=zft@maV>7t2Y7Cwsb#|Hh9BI@G;+BQ-a$D|{nJG?>`tGPI&V+HOaIv7p8)%P0 zF#0?yH}DdKu34TNKoqz_jqzhofgnd&C=l2$A0w?wDt?5ge0G^QOoXOCFE%ZfWnZ)= zaxYW9`;?0`snc(EfNnqgSKHt4Ers`~=jRnk#Fax2u0e3&2fx$NhrBY?zImBAK8e-= z?&tpW&QDloolG|XE`m>pqN)mD>O&FH;_d#WJ4|>d;qr7Mcj$6+^KxkFK+X2t=vG5j zdZU%_#@4oqdCsL*-N9Uf(k*x7$p@sFoYG}@;FL&&hQ5uP-a>`RSxQum7vgYbfvG3o zvqG?h@S>0(f45eg4Fe!&#l*l_Gsn_~PK{B3} zfqF@RFmvx8VPWf^>kU#F#x6eWmCwxpne}gyj(M1sE1 zS(=k3TfAsB(CNvH-0f+ir=-N40-cqcKiut`MzKGq3ZH0zr6X)kKvB#RxdAtE;WARh zi@RfX5ttG~6wW5v4#~}@?u_&mYHJ<1%<|cxkR=25Xir4%pA4ZGfl63ttnw}VsN+3(jK2e8C3On{ zsy{4KvfjRfM!@_emTzIloU^BYXo}SIJ_vt552i^DMeyEol`Hv!hHnHYu zKak?|VcxT_I>3!e=->M{##jwYug%(@*!Zsc6>pTY3KfkD*YDpyyqJC+JnIhBnPIc(# z=-1A{oVtys5oWXx8egq;!|33M{#gMIN}Qhtib^$*$Bd89jN<`l=!Cho8!CXIgFw(n z)aXU2q;OSuup%Icrvvuc?x&-FUB`Ya2jxsRE6Nk4Ud*Q&ym$TXqc6x0OY{T*$I36F ztD4(`Z*pkj6jub-PAu+wj1cOplx>NV>)($1jz>^1SvHQ&2X0)Vd#ECePq6(dT zjGOm_Btov4eyzaPl!K$rNuG=Mnf;~6jDHo9RH!Ac-g1JEK2QIhkHEaEpctHg#B`vl z=6vfpqQq{8!qK2N2QeO4uX)KF|yV&4OxzK?a0L?`GY_aCY=^JyJqzZH%ix(@lA?wn z&u-%1_pDg{BnG{fTL^{~kZ}ta{wL;Qt*6Ve<7<`Mj0Ia(PcLi$c>W%0fs$f{1ODuy zCd&7gyc5~}NrwS|DMc?3xW&;v=+!UVjZL1f+b;N@A~0|D0D+U3SRnTB7Hb|U3^};w zyzn1wj#s`zU=$e@K{@+aCr$Hbb{2+_5hnOeMac;(608M3v zzarDKH{3C6evI`ym5Oo17=M$M!%ANobuF@8bs_&e?YU|pd^8Tyl*@(G$zzRH$7nic z^vDx?$&J+=kKyWodd;7$psD3=R8Yf{5%l@-RO%x9F3pzcOb8*Hl65bvcuzw&NqX(! z(ASGkedcDkyB%oNvFUeHD-S)2id`mduMpN>y{T7!y!=Bk=H*s5$aWPrD*grT`%M0) zB2(Qb%Whvl?!aK=g;|cA3o}ov`-?H1z(mL=chg{8Mcq*ST6;rOdUj_niAd>=0)a=( zVaxKGIn~n3)kO|UaZb72OfFtb6oflL&oKt_owrmbs1^Zd-MNB9MxP=(Rg8sU^!1>R zKyiPwXfPK8VHAJokbq5u((+zi(MWZmFtGU)(eq zg}vJTVsLy4|C{%ovLyi9E~Uj8)Nv6YfgmXR1|H+j&i3XN!j92o<11VlfME}0={0RH zV_x(p5j;nvk^RlMuX*Kf-cFq!O}M5m8khG6k9E^^Q0BH>2}SbEj(bE0fvy7u4^%ToROuujq zQp=x(W)yqYXfHJhiJpXdm=Sgl7Cw1zlkxb)Gso3J@6=G zyV$u2?hxgv%{B_9lRl?v=(OhO34XY9H{1jBH$GK}AMV?(DHu<;#E=nP@u?4?3C8qZ zC0Bfp&Tj@unI-;k(*jQpOS~L#^Nuv%@UDMB|8jyf!R+p74jm@toDX)Op!x{E$f?~9 zamg{M*>`DRCpFqFov${F7L_bh#QHX*&gEZ^G4Nozqm-b=rcUBP_1)|CR9ca|gMmG1 z5F4JlhbJkCYZp$OruRb0KG>G}y-ImxhU#S+Q#li>J-hxDlI{crptsdlHtr!l&~sg7 z_gK27cB}K^uFBIhzhOcvC9O3W4<@ZZ4fC)E;GaFH#IUo^cuh@tN>qqZ{W{euvzqLd z<#sK2X|!A$bl{qM90Qg*AKNXH1C#=!D7PzNK4QRu2(fZMTrL}K9UK=`h1QK^{Jfro zm=lXc0@C~9mW*an@qM>VBU5!r7qpGi?JW^@m;F0L%Hp=u`EB++G+R_i75yu;8wR>} zXal+5Z{3ixnCSa`(sA%U<;QZp&oMf+?+g-4s(Oz+yb`>EsiUJP7`!l>{)!M(j${(H z36NyHC7rwyRZfbt&}YvmgbP#CS~$YqYyH;i2vJTc{rkQji~Nzwo7L(Rn-OQ)oC=PK0D$6A51h`o1_)@Jbhsr0v@ za;zrwcv}y|T`JF-Eo&oH5mEnwtfmdZ69}TbNB(^kJp!}BDpA(+px9#}aP?wh{J^X8 z=lY`VYdTn9wSvsI+SvE6EKdcw-!b2+i&ZaoPe`|a&%cqIB5lt`SHLBEd+>1r1obnR zF^=fU^Hx-I>d!ziDk@2oRn1%BQU^q#s;nUV&maS3Z67N7+w!BUFZ;^%A!hlnopg|A z10#3CRc>D7YW!;afhYiadsN5>U(G9jPB{%uR63VTC&i3xIA1TlamQHLeHIUuaAGE#N%CY5v7?A;Pc5RHUB_l_ZcG8sf#%Z(EI<|+i;2m+ZKim9Crna1|t&Ft5OU@FXx+viPzExW;m0#HLLOlGoq z!+@~qix$fT=SAm>EJ2rU(-e*b=-{Q}J{+D;06429jo?Y;*ZvF32jV?3?im>ol_*Y0 zVCFtW*4ic~{AA&Dzw2~<4&S}IwcnC3-!-?3$0sb?e5Af>!%VL5-9N%n`J5J!RC8aM zmdVnv#q}X8nibD?!3)zP2X1R)%7?tO`?j7Yq)m>G>6vLaf4(;kW_b?%dPeGL%dEe8SmrReF#?4Yy{E>ywTTP+|~+FnU2_p%x6 zf3Xt{B^)PyrnpXR=<09_`-a1yX|kbAp5Pd8emd-ITXN#%lF~?w7=n>15=_S{1lUu$g|%OnCXC$c)V|G6-=4joIT8paw+H|!UdN; z25zBiADR1Am%IDc|0L|Y@BZ7Ib5Kw)cu4(PmONd_@>~xz><5)>^c)g-DM;NkzUU=G zc^ApP_1@t`rK2e{RYve41R>yH&sE0s+4y2&JC&Cvz{vwROdOiPusZW2uC9)rd26Jz zQ53H=Unv_qPL7PpciF0yuzu4sF-YQOf6&0}ep*xBcSPp4Xp2R9@ z%GJL$ivGshL2WpV7u7jv-6q(RVXX1(BBJlBgaUjuS7z)$wdZ^Y5h_)BZ}k5WC5 z#q435Y&Wc#8A-F_qFh2<;B+f@;KuX=+VIE?q;F)sGA|blR|fkOW^pa(LjIyW39Z@K zNPq%Sr=JR>$=nTbQyDnu|Q-T_} zxXy1q;~*(ZnihpC>Whs;z#Jr3)gj}4-|pvmfF%jNcN8Z@_&_{Ug`#V+^&8k7R%XAf zFhG$9Dj=$0I`tL6O2#e@N^*#OpsDXBH4H^M+zx3iMif2DTWYse=loRh1ow}ms!);F zWsmhm3tjPb%HhUOed1glgf77-z_)zZ@!<>d3sMSD8{p&s0#bE}pS(9*2?>=%P?fP# zLJfOJepRBi`CT%B{D?>Hda7liKTHyPspJ?3S2@`l{SUc8iS5Qq&&ND$4hDiB3G>M4 zlP2q``Pks6O7lrO;t<>k;5*p)Uo=ZzW;$GEz37Z@rad`BsW}2}(f;@8Rx%~Sp_&Vi z85z%a=AV5=M2|H`Qx1&fJUd$=pHTT_qugf@^bqeV0rS>wnzBSsD!5^ciC8Nf`@CF{ zh;{cb9CTJIH~KwTS!abdiAd6NksMi*L^x7GRojA%9+a!=Os=`n(4XU4EcFAxg>u7aafhBCB-bz&K_3 zuc9v<$f4_W68(TY`Ngab93g1k0jf)cRM`5litJG39^7QxQU4q@NeA}6t5&JM+&@tv z%@~@;MHPm=GQhU;0dE;FRkuD8!$$CWpoEmBjC3fHh$kqhemvuISOi)v)CQA6;-$Y{ zQ7|_)i1R=FsfgU#WeH6$yyOE8_P6bGt%w=@=5TM4?64^eD|yvo%iG@;e;z}8p;$)K z44Lrt=pLY6%!t@G=Z6Wq*2d%n1H>0$RxkRNj=iH}PM#8_2CeObqqnB4421Dd>%6OW zi>$!iCjci^h&U8`?xQk%Pqc2Va{a&QBY34So?Y7wGoqw{S_B@=a-n=JgqLUz5`H`y zG2BzWpr8rP{Js9~c<`jEKVN_GvsE!_6s!{~{bv)nl9YMj1Tj$jAW)k{cFhwaNa;rB487)tKotPl8;-4bvv(>63rfFn@ z|N4HgY&<^U&Zb@`eEd?j`prX0obR`=!}x0N{UY5=?xwX=p}kTQ+sX>XMJTF4QAug& zy1^Dt*jv*HKN$9VfkU$m!B!CQTU7#xbC6OH_ml z8h?8KmlxO1n-kw__#-?rCn?iVlT%Zz`Obyu5Bb{Yy4UDXpC){o!~FM}n5%I30>uJ+v!%Z7~GEdKcvgcd*AwQ6bvf`A04%p$Hjdh=!gP@M?p%o ztDB$NKS9bFPokcM@ZgYt+_SNlTA#bG*lQ--)ee))s!K!sO3cO0=#Q(mp%uOz+n{~j z9b6Hxp}lQZDwEuT-{Q*kwIvq@&frN;!H=ue#n4Xr4T$L$+*c2_a^fyWL2p#neO1x& zSpM6ew~736JR5@SY#bKT?+=lN z%hANIFPr1$i$TF%$FwUUz}(fh>4HtSLOBD7okBVZ5Zz1ze@;xbvVNrh2OO)!jRbWn z(Rb+ls{~pMBp|7*;tj#P5>R{=G*Qs%M4{&E?{o2f_M9#@9J$9=(C9A%7l_mJb z{+{%XhdHd=B`u(AJKUDDdtONEzFhezPXsKioSQ6_6Dl7 zP!r8`$&D9rxfm%R!4Wb`j-8`0B&=GnlXczrsN0$tK-EDESSi$2+&6u#sPoFDv7=we zZ$td&9a^!UTI`+1;>Y_Lh)cJ`bADY!DRp)&#q=%}!xJRk>m@Aw0p`zM)ynqXPD%F0 z4Xcqyblfl3D5LDsCdnGE?t2poSU4tE2pF+*@SfjL0^$9N3+QWo7Kn462`2M7Ze-!& z$MTneOU}Bb$K&fzqY9LvPLiXD-yzLNFlFJVJ`*1AiV*vupe3>Tu&v(yr}dwU zy%i3pi_Zg{WLAbuiiW>C_Wk?QdzzJOD{aUg&#e>}c~NIZ8aecFU-zbVlX`3A_kh_U z?w^1nL(r#MF6n+Z8jJ+Z&Yw!`vtOldOYY=ZcIo}nPk_#!j}o7#r5C?{@b2k4a+?>> z5Z`450{qJE`EtT(1XVfs;)ve9Wz_i1N^Pr;P z4;%RLYg*G`x&#nepLl~#M{WTjZjCYQJZR+%RW7yL_${letJb8^U?WM0bLCJD>3Spc zs_0IjK=sJ`1M{El%8oazC)`T5tf+JAw>-`Q5!2(O;`{-%6-;&CrTLB=UO#Vrdyvn+ zJd`Q)Gc(Ez`!qj9SL&(x$*YUsYn)UFfc-=vtW-)X&NI$9)-c7Ess(Z- z)x+=hoqJjTM#?S>;Zpk`JI@d9VYlo_{7PfEaOnVU_gTno+oXYlXdR!V(0%VoJ2LAj z=&0=s3m7d#FWl^23X<1EQN+#tm1r*AKwM^?Ut1{n{FLphq)eV}swGK-v2oF{x8*at zHDj`pZ&s}e?h5y(%M_%WEhbP?9zp|Uv-%&j7p_Jwz7>l7Q=2k^`K2RM&s##h9m|Yy z$TpD0yoitf9`s2Q6L5ESi(8VW^r4OlmU(LrNi7*Ez{tt3z;NKTD|hC`u(mm=l$Q0K zv0=s^zl(^!!sUK#(EOXj(u3lK2a+zj1^$P>E`mz3i1Eu4RANB^CmAGmN(>%DHT!Mf zq395i^Gwwv8iTv$IlL#lGez4Nq48vtHswCQWb-aVPQ#DLU`U`&i#G&EbJWc$`+_w( z%lSSlZ7GXesvY%5Xq{)xy?Es}aq*5P-aOJWZ%8&?C2fDy)U~V$IB8|`u&{zxzsLW% zRkoaYvz|$P9cZd$Hj7b~b1d{1c4BH}2o)FGp+^6SU$709$`CmQUEaSFKzO1D=2Sl6 zW$@-y*h8;Wy^G6m|B7#+WwdAKoXB1&sdrROK!0TOU0U`zb*VEq&k^-s)uHNmx*M>x z5&WZri>49S^P9k@ftfGO3_)7IiFzJ&wn6j3m}8ttPTybymj`|uFI^hq^>#q%n692& zKwW8)FIxqU>zfe9uoeBX`3_d66kk8f$7b7pQeRQSiaUfzKK>y(;iYarrI-20`bcA{ z$myNChQuKDRu?Y~Cke3ZiS8ATD>_zjya|{NZnIrF^WsLSckXi{lL96|aut^u!`)C3 zjVCv%BkSL#D;aZ|DGjtob@~{x&-)zzc}%Wa5z>?f5}jrlS{GS8f5+4LWl$|I1o2p7fh4R z>L=66zWN*04O*}1#=okM+YaCAsjed5UlJ4lO>$pdLl5T*o^u=WGJI%=)=bS&Ngf867w%7vPuw^@Z>qsSIi^m-zr_JON=De9iD0IAiaT3+Kk zr(u*U|Loq4*8x?#yhodx+ZO-**1kpKV90JoXbHH>)@e!KM|%R&mG;wzl6GshPEt&> z_ps|g9kuY`w8^?^K)zcTX`{t{$q-;^QQ~w#{pidz4_v=5r-6%NO-g!ikY>=&aTebO z??-$dzq0mJD*QU1*J_D8P3KH?*YZf)rP2BOmGyKL=bOODaGhr9D(b}`?THlJ(Ofg) z-SUg_=&1MtT(@%atb|Y~6c&~^q)APYJr51{W5We9W3(oyB3=Ib!$U6{#_APkPaHq9 z_|=}2W4l59QPYJDPSc4$X)YGznA#Q5&r^g_paC+(-z%h zFBzZ{cWwGa%Otj$InRzHYibY$J6`YYJK8xR=;1~@v?Bl^|2Msjzd=loI^=o0)YB;C?TA8Y zWC!SR<LyT z5;ixcmk}8fomfOMYi=#eBM=+IDG9OSZ-9G;&DIlEylH>5_D)B?)}HvFm1$%w|f zXnbi}(Va1Duq5)V-~xJ-1FV|c=!2gfU$x#0;`O76F(jjsyX~C+mQVT5?N5qx#zXP-)i4QsW##Mrn)X}s%!H-|_27Wo;-gRo)#+L!_&P?07 zdgwEfc)_1tyS7_O0^l#J*-(HmN)qgcAB{NffOvhsG^EQACc30Q$Q^(^P1B*pT@EQ? ze_x7ehV-0UkgN>H!1uFE=`-?)A(p~$@Vg_Q))r_-wKOAk)~`b*o2oy@`l%8P7XT~kciEn?&me|Fn zSU*xYd4wzeeH?eaq{wQbPJ#cD$Ng3O+438E>AeB(1*xW~%9FY)JNKrWCgwl!3UeVK z2wGLb6Z@0zfmg~`P<>6c6R7uo*O&IgfFqUKXdo*b_&GIRe`ay2e0C%TGuppu**j5< zf+)Vzg&<03ReL5~?Hfb=uC-mfZlE~~jpIA)d2U6JC)61Lvk1saa&7~(mph{DSZ(AU zhPej-AuC+vi)8^6nxmc)?edO9>)6N+Jurd|QiYycNFqKkYWXZdfum{4MWS6S+JXU< z4zxUl^~w@*yj$9Ou1CEwum?Uo#yG%wk2*3LBw@5yV5Ns!vgPTg0sEhn;6dfb52|@w zl|gz{xi`R>eZaMfnDjA{>0(TY_m#$L*+@{641C`3RC14rs^U`W74U-$LW})%hEj=q zVndo}BMH!}VgQ*(562IMo0tQQtC{kqNto5Pb%1l0?XBUNI4xk79Z{7CS7&2NUhTHs$C@O%fpiCC*08Si%mz<<()n>HBm-?@y%bz*$IhM4E z%Y-~j14&PV@-v6`;{gb)SLL|QVmIQB?G=nooLGp)5EbcFHesg!T=8EH4!9_(XR>o` zJ@fL1#0G>32q(5EL86EGG1HGWUxbMrZ+~OS61+9t8BB2x=<^?;#Cg=17jb)VS|Pr(eVi~*Oa8V-V*>!}RbD~F!< z7_|mo+WUmOR-&yE*H~dtt#3FuJzU4izhkA&4PgmnXp31VAduD%bT8SFJ?StvIre)a z+peCLQ6!e~n7a=fi(j=F#OAaco@PN_m*GlY`8#Ng|NRN)rMCQ>JUTK5DOUCSjwX<*K%oKbzQ6yl>0T)Z7}ejD0CIL#zJ0*iqamn@e0n$myz^u8HIXBV zkLc4M-lkxpRjsh@==&rXt?9(_B+!dNK(E#+)$8TxJ@x-akBz{N@}aM-AiGh?fVznZ zD;}PT5X3k(eAq1^J3906AWQjUa0h@sNCmhiE|C~t5|J-N!xS0$R4AW#$5hHu7+3&q zU~e1cd2n?0T`46G*Eiv$B|36C0Xq`#nu4)jYyN`7+5$EEg9b*o_l{50RvsPxFWV#} z+ZYoz$k1FZf= zRDb~2Nm>5&09OE(r@%f`fEocp&!qUz17iOcLA25u0YcxT_|FH5^H8k-AO1T) zoxk!?BS7er6#sdED*(GyfT3Bu7|8Ks+A+!m3DDZE?7!nk1qgMB5MW{|B;;h|nh!)RcnR`4P>ukP z!prq{QXc|@1|$eDF&8A{WaK(5f@I6%xEy>6tlh(6$4cp^6as`A1PE}xF&Av+U2iO5Ym>;#EEehdc^pssYfl+33T0)%S}FTljql8}>;EAB*U6kzbO%!aK1tIHis zfuS423vg{}S(B5Ia{_GL3YRTD27?GtAAj6$8K%I{jo}5jF}1vslaV|10T!1cT@nsZ z0p^w~TK%X1K>{}m&@@0!MlN3knTk6Mq3S#Y2qHj$p5=s{2BR(IK{YOS6#rJd!D%pX z&hP?!HMMNW$;h25z`39kp{u4-RDd7@7C}7Axe9PV0qQ^B_*K?>nF516XoK-AH~g(| z+6~%SP1^8@gnUWKcn+o(y}$%)9#iW8y#>l9d@F7hb#IppGc`6Onr!SFgqF zSAg2t&&_{To%RqQNM|*IhbNBhZ|nr<3c%h3sEbZN_jiTj4Fm|%L4c1>JaYbXQ{Z0P zV4U+BzWA$qS0O+U3j*|l*cq_THke|QZ+EBwK{g2R@rla072tET0N*ofz1?t3brhhPl8Z#IAU-XEW+04>Lk z!N`^5EYi#87R~r-t$G4{CucDo1PJ~;5a1w)KV83l5PCL^IFn}iyfjt4)b_oi0wnCz z7Q|v81PBNh72v}>PlZVHEb)AZTtK<50QNRPzHT?0$49njI+dJT0X8durq&UUq~gB? z7$y)PAY4#@NiOy*4!?THg_P?ANRPpM+mz*_h3#3d6z7p%)|EC|Yx`cQ=s%~1hnsUW0T|8wL5%Eb-tLFjGS;0SQFR+96oHpWy&E5@Z2AQ~VTGsgrt7d_^^ z0my}v>kII9TNGu#0p8mR$QZBM0%+e!sW1fwaZGt3?|B8MClVp3NT=gPibO=b!L z1f#|;z=iOTJ428QDc2L=qVP39W(7El?v2syEV#N?6JGq?vyb`TkOk=QVb0cjGFInuRQtJ-;oO`M+%@7 zVAlGtGaw^OzdTLujL|KV7sVhzFmAj8%mu|i_d+hDocl|-yr5HH%FEMi_A0=cWDp=2 zIbH##!b?iWAQw_Db}C@8!y`Z@uO_B84O%h?5R4tK0L2mL8013Axtkzqr@u~sF0!iY z+GxeuX#zaUP6z}D!U6&uM2?&Tw50t8=m!lDj2=vZ2at=KO%O5#E_eQe00F=z$QW|3 z&jh(?9Jt#ADbWA{_>chS!b?WSAQw{3y$@?%kP65lKmbk>;Dhjz&@sq`l#AUtFcqMJ z00B5jfd0AH!Wn{GNV(X(5HKfefCd5t0GA;QI2X4+1t9`V$TsjsLVy6^I;^)e0%L*h<90M)3u-@MQMqJtuR=VKT(sILI()a{CKroF3)m!cYh~__bZiu2n;aH>h1NfWBhO)Sgd&mm4^We=2xTt{2>nb^yhel5c=u3vK)QOC_$Q2k^A3#y-Zs;21|_NAY=uzohT`q|ghPbsS( zBKqx3MA}li<{!`I)L)EV2z>+WemDAJzF`Sxo7WBu_fFsbDwSKJ8ekdB{BHrB0a^!K zssrxifs>)n*j;wYEk>JG1MHmTm;n+MXY9|}I=u*snIXP@Z$xHpuaczJvoB@ghI+i`ouCFs|j0}FyM&rH1KYVGpDwSKJ8el1?0m@|_0dYWB zLdXikG~+lEdBtI!j^V`KNhy%dWRhBuaWMBcb`AfZsRmdI_9L+VH^_4w5D&x! z=gV}?CY8b%?>l`Q4m(ur$*KXC0tP6k0pfst3&aN9)xAQ@!v=Vd&NVvY;yyi#jG39+ z@{-<*wy|#6?ieZ+&ewQ4?SsKwjM+S-Kbp=o7zVrQpCsrH5f6#$r%xxMKavp*Lx1Ft zGHDNwYJjCNlb2_J)&a3VJTT1iPxtTL6cg=lhDmHKC+86LPck-)tW73|rJvwz{nY>q zU>5j^SUdt+2gCyxb8Ik?(deGAX=Ef_1Xd%={r=tfYQFwR&1!(9Fq11}fPxqx7APzL=5rnj-B5Zrq<3#=2ct@* zRH^}%!py->#TTCF*MKjuKv*O(U%=7mcyug``XX2v0w2Dj}wS zL=h^hOiw>?A^l!qZWt9?fxdpbjmqll?~bMP^jl1A>HyWy6Vp$SyLgk%ggvSO76jh` z_Pzyl7RXbeH9@%&`E*>Vlu9+gG6;SK?ktdpK;c<{g}fr19S=4fAG^l2zvu4Sf>2t5 zb>xN6NJk)H+Ug>6JC+$kk{JlnFlU)c8jl)+xXW1gVLhmy9aRctsOuW0gaUQCOeVSq z2O-ltF#*WbQzJU6|Jcf~=R}aMuvI`m+xhzKi&CTrM1$@u^!2MSho_(0#6|V9KqUkc zKtHGkSPuFK_y{@+d?9omC|49*k#_NQP^pwkHNbM13H(%4{SfSZ4h9|u<-O@>8Ts9^ zg#j(yz_xBMVBHMdlDusSBU_H1Kp2p~So47udn77rnojjhGNku*CY)FqaRCv;lRzu< z^s|**s$UZXRZLG6Zn_ZCBKqBJ2A<%7oibDXdaRIsJxoiKub-1y{kFWPpFvnZo5l6B znLRlZHl(hn-|pViUk$JzFu>lYU>*W_43sOzv)*jkRsaU=|4m8ys{xk6OuqySJ0K>A z4azH#k8RV)-;}0xts8HKu9q2$+}uO~i;YVq0T{B(lvsfnnqZou`F6%hLC-)aZ$FRL zuWsW#{lRe1nn^++uAe@t-!HDU!W*`6SdW|}4*HV`>sOMCdHVNlfa|N5CnqOY>+{{x z&fkCgKAldVK7Mc~gUinss~z{d_NNLk&>B1(Zv{16S)e!g?h}C>PDvfa$ZQ zqiyy^ySLmMEY$8Vu+6X3w%PSGHk{Pb>|KA`^{sYMtk^c7pL<%iWf-f}>fXd=8sPcW zNypU=7P0y^?RY8{k;CiN4*Pw!d8O-ys_ksd?|ePc4t{WN~?sy5Gg+c3$cFcn0i01fKF32rIk~Rxe_726%L|X&GzB zhzUV-tViiZk__P;nZ!1T7Yvt(oH|v%7n`O(pZ)5OpUBf6UOq(q`!qoOAaQz0<0r`eddTC_fCcKYsjOMnAG`Dss<_$DpHS0S{gIP!i;hI5=-0UklR&+pV1U{F2kQ)f`0EaF5y&fD-}e6mLuY>* zc>V)eoklpSR7xeo0MAbXBz#Y{`abRRAoEk=Dq+9NP+sZ!djA!S1F8u=dK2JD!~pLe zIquTB>#kh(KE0jkTN4>lf@SYb9Ub{wuR>OyTY1Sho{{7UJcV}j2X4QZi*?vFAot5aO;PK6M zct5}2i*DVe^9up|?z7;KKrp=kzKTbKVEf|r%y;|*6cZy1tPX#K_G>wg$SGHAxCwvEh5RYwspbkYMo$EA z?G2zXf5UPg_$|^p>}U5oQ+L_??(X9bP;UeZ=-*E$1r-*d+up4c5DZdWTB|VY;6} z3aC6LvRnQtRr}LSoGQh&C+X@z4N=bq>PRg$7NZvt)mbBa@t3DUTZmZ^gY!2QV)n9!O#m;B-+00|-xpVpcwU{sqxiJA0%Jm0&xQoIvn5%-7x_l6*}j}7yWlV5 zkG|(MIU#R?vgc>#K@CF4l&#?5+UCB4MzpG};K?K7uLS4Z+B#e9giD+S3h|goQW+iB z+yP$Q>*IfUiFo<^#)=po01*18m%xUH=kRp69NZo@EWkDR^Z)A~f}{d*0Uv_=1%6{a z{AI9i1dmtuLhxb~j%Nk%$*eSEmEE zey;4nul+*d=X3k*&(vKLe)dM7Foy6Ue!~6%qE}N;K&H1`a!irwteQ7#_%2Ly2Kw~BrIm0u?JVt&8(-MZ_-?{pK$cZLX+ z-<3bEj#_uT4RxwdhB`(-lE-qHK*De%tOnG1jFH~h(o(gj7++N?@RP!($hWj!zQS@C zYxsxZTo>>M&fnP(T*x0df6vMIYgbi>fA|iM`j#F6^ZbGH2RZ-khap@jzbb(B5FSj= zz`lOW6Gse~`||?$A^!E7kqMIfy3CL1kkR^xetFnQGtCnoeFqSNz6ltwl!37{Oh z_!K1mrA-Ns<|?Nhv0>VYv!?b2y<){Le!2wMR~zHC7opV8`L|orzfGi6#Uem1202nx zrHX$bGMQBb|8SH>b;ci`Eb*rTkk`-x9sVq+qxA|m<3Hs5F`PfCf$hg(T`IpZh6&U7 zfcFC@W8&9};KVPaZagP|H_DBQK}x_pNk`S4X*K+5iUXG?h$J&ft9@t~z94U)P~k97 zJ|ae@wZWysjR9fen$c3wp_^4#{z%1-@E@8j2WL`?6ve@1YQUM;3BeL~O(-j}jun3&|2r&b46v4zm zW9jPZCOM5f0%YR@>PWyk=@JLgc(5Y5)(Xvxf5m$Kg7I%!S!tI)=<}ED{257tC9|EA znc|O)N2u*7IDhAp_z8c+P5f~q?)(XgLm6s~80vGJYc0ZFM=K({^85ED5KbntlFK?gf|bw&5Ol@}Rm*eqE{QfBgwi+z$uuad3J za{dm>a;<6tG86vVqsL#K=l%$PRe3|_x19gbW{Eea^=P%`D3yHS>ThXGn+ywBN0O>>Hu;m9dkBV03 z$*w0=KKJ#@lt+{2G4|uOH_3QdEK}u>qHA%~}bN`e1n{~-_k-wQ1qSn%rMISN9H49cpu{DyTuyLOfyH2BJ7sK}WzWmh zV*Ux%@OL*)reTT0RG`PdtQW2PhO&58e&(gDQc~|CBaU6c-2`yMJl^&1V|EOzriy^! z>Ls{b*#c{s2Dp>M+XAPUEs}wqp)6HIC&j!{X7k*XV!kLlCtnsz>8>Nr#q)gL9!n!rv~a1^lVqVWj-kz7uu+ zh0!a-pIDfdU|fv9C9J}S2 zq{>_%BDzKuF~b{qJm;2lx0TG#qq-vDufh2Bb&i0aVSl+;oLxDZs9O|Mk74;@2)XczHUKwnz5y$AT2y>rQtA_ju!iZ!>WyTBRPv18475&O9W2t8GV zo|2rR#nMSnciH)|1K}XE?5;WTRK8UK!kQHIXfdIGk!H-H*YBY(t!_I=r(-SuK_>j8 zRWI?K0GXdW`T`u5fpHtc;vl3kX5c1*lbhe&6c~w4oHv ziJ9>mH!j9+>=|i0^8ke1zs%B7|mkBE=(8w`;QAv~iwq)V9| z7u|wCa}LJ%;Un}Jm_Cmk1PLen&QW~3ZWO4Z20 z4+Hg3xMeVWxQ>N5RH~|B&CIj_>yMxcThI96Po{ScLLb@XlTX?`7d^_TX^YO+P8yrX zthHN)a{w}dH0#m_OIVge<{{%yi13FLi$F}Ft_oXy`&MKpSBeWf;|I))ADB&SR~4vD zowQ&b911@g=H*lp6F&41FX5vPL;UEY8%a-xpYb08Jo4Zapqb@LpUCJMl9os<&gIO= zl2>}YI|AK1C5EQlzh;TKhi3c<)hm2R=A!YIL(>#5<0-nrET;3lzD`#?RFjxX$n-%< zffXG+3`hrtDB2$WL|HVKGBv>PuDmnMiZz(%2-^uiF@iQZ?l}m3VMv@C=s5;+WS0-?7O# zJQH>*@b?hFUh&_3!OzIX-(8prKOn z?_3ta#>&u*{HF@b& z1tRObdxg(bB~ew=dY?PtP_`9H02LGfQ5PpB8*$c^S7xO>+FF|0Iu(BWh(%wwP`ivR zc2nKTfI5(4qQFKuDEOH+F9zcpmGR?)1~N2$f|O@xdw2X>r!ANeYnh0BFghH zgd@)n!8B29ROMA0U%Of`aIWO2E{?*k^fgKLw0k*tYj)fRMpV)Y1pcN}{ z&FBmMUhTb)i&ID?p(}k8el%ha1Pnexf*Gcbkw3)CtlCwPab^}Kz)WBG6S)Qd1p6EQ z^IPGLyi)-`e}Kun{h%wa7P8>1c-*X}|=X+X1s%<^UymvvH7k-3G)QaxF zN|chjjrgt1=m{YF-Y-UxtW7m(@ar5gkrI`c@P8D3KD{fEo!o#9EeXH#NA!ik{{>># z9z+P}Tpj{EvdWkFTfe;uOm^Nol@5QdWEH=3O0C2#xs?-jYb2Kxy=3i`t`u`EJKh7|l}D25Uv8$WZQ zBarni5Ye7e|332)!KuN3U+1O0f1LKo0FP|)Va@<_3_%puDO^ks=yGh@EL9Vz|BWhZ?!U{X7lk$R=N%qUq~b(|0Sbq>R$fj{}I9 zbvlPE(sfd0B5AR49I6hIRjNYlw5bsMiCJ`avGjNa2*Wjg*O=HSMJ;!K?-rxD_1Mfj zt+*B2(C1^q3D@`0+A8xaRdcg)ZM-0(#?MEOmym@&p_xGA?|0+Jp)nKul)y(8dF#!I z&{3YaH}8Mi`tG{?EwnJA{-dUwXnKy@rS!%OE8|A|IT1!8MLtLpa|-$r#S zX8dS-R4rR4Vgjfd6sM?*SZFxWi*@72&HokvMr7kp(K+BRexkkLr>C471{{tCahZ=Y zj;91ZvdWj~z(y)Kn)4o~e7e4~xRh=J%s_;`eDj^S41jU}G^b@z1P$F?9o=4uOEB!j zsK(##$+ALY3x0j=q2uFR;8pRtw#@v>-1xBze((u@$i&rWM4_cZ@E~aEa0VLU zYG*!$C|&80e~dEfA;4elon3AwAq)hMu(#OzpJ>mrFC}%QL{Jjt5i388je(x%>ln-o zPd=4@A7)h{r5+yk*L%szT!#m9ZBHqCjuYx-0C`N6f;UNU)l6$w3Ter~rSR*jjE61w z&o6|^#FJVS2dDVm1EbHVQ5L^utXNL?(S3$r%&1zrpX*|EM39~NVhZ!p?+8pa%$5!v z8=w|H(*G6Gy*0ommlVkHd^2yJEz|A7k1O;u9=c18VS!jZ5pUGM6KVH#8-Zl{dwCsz zXgT;Po$&V&2AcMq&-E6U82|ash4NN+AShuE`HeJsV1}v4#3%1#z(sw-5ezJG%QD^Y zBUEw)-_zp9x;}B9eR_@h9g6hik^<3d+_Hz?Q68n^GMeKh*SQ^Y<{cneor;!=W@sEK zY`i9_CI*_6MQ189el&coG)?e#4@)C4p`(ywZeu7~pux->jo&UIJ)9JkWlYBJQI^6S z2lBi48J3x;!C3J-71KY5-P;x&9q|joOM*Q4?t2hKAy8oXZc$qo!*XD@LzHy^s@$tP{-(h1_|g0T|DQX4$Ru@k`5XAHEq-@i{1l77K&+%i`JM4& b{wDtaU9#Ss{&OGS00000NkvXXu0mjf=D5g< literal 0 HcmV?d00001 diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/assets/logo.svg b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/logo.svg new file mode 100644 index 00000000000000..2284a425b5add2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/assets/meta_engine.svg b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/meta_engine.svg new file mode 100644 index 00000000000000..4e01e9a0b34fb9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/assets/meta_engine.svg @@ -0,0 +1,4 @@ + + + + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx new file mode 100644 index 00000000000000..9bb5cd3bffdf52 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx @@ -0,0 +1,74 @@ +/* + * 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, { useContext } from 'react'; +import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { sendTelemetry } from '../../../shared/telemetry'; +import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { KibanaContext, IKibanaContext } from '../../../index'; + +import { EngineOverviewHeader } from '../engine_overview_header'; + +import './empty_states.scss'; + +export const EmptyState: React.FC = () => { + const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext; + + const buttonProps = { + href: `${enterpriseSearchUrl}/as/engines/new`, + target: '_blank', + onClick: () => + sendTelemetry({ + http, + product: 'app_search', + action: 'clicked', + metric: 'create_first_engine_button', + }), + }; + + return ( + + + + + + + + + + } + titleSize="l" + body={ +

+ +

+ } + actions={ + + + + } + /> +
+
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss new file mode 100644 index 00000000000000..01b0903add5598 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.scss @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Empty/Error UI states + */ +.emptyState { + min-height: $euiSizeXXL * 11.25; + display: flex; + flex-direction: column; + justify-content: center; + + &__prompt > .euiIcon { + margin-bottom: $euiSizeS; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx new file mode 100644 index 00000000000000..12bf0035641039 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx @@ -0,0 +1,53 @@ +/* + * 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 '../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiEmptyPrompt, EuiButton, EuiLoadingContent } from '@elastic/eui'; + +jest.mock('../../../shared/telemetry', () => ({ + sendTelemetry: jest.fn(), + SendAppSearchTelemetry: jest.fn(), +})); +import { sendTelemetry } from '../../../shared/telemetry'; + +import { ErrorState, EmptyState, LoadingState } from './'; + +describe('ErrorState', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); +}); + +describe('EmptyState', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); + + it('sends telemetry on create first engine click', () => { + const wrapper = shallow(); + const prompt = wrapper.find(EuiEmptyPrompt).dive(); + const button = prompt.find(EuiButton); + + button.simulate('click'); + expect(sendTelemetry).toHaveBeenCalled(); + (sendTelemetry as jest.Mock).mockClear(); + }); +}); + +describe('LoadingState', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiLoadingContent)).toHaveLength(2); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx new file mode 100644 index 00000000000000..d8eeff2aba1c69 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; +import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiCode } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiButton } from '../../../shared/react_router_helpers'; +import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; +import { KibanaContext, IKibanaContext } from '../../../index'; +import { EngineOverviewHeader } from '../engine_overview_header'; + +import './empty_states.scss'; + +export const ErrorState: React.FC = () => { + const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; + + return ( + + + + + + + + + + + } + titleSize="l" + body={ + <> +

+ {enterpriseSearchUrl}, + }} + /> +

+
    +
  1. + config/kibana.yml, + }} + /> +
  2. +
  3. + +
  4. +
  5. + [enterpriseSearch][plugins], + }} + /> +
  6. +
+ + } + actions={ + + + + } + /> +
+
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts new file mode 100644 index 00000000000000..e92bf214c4cc75 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { LoadingState } from './loading_state'; +export { EmptyState } from './empty_state'; +export { ErrorState } from './error_state'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx new file mode 100644 index 00000000000000..2be917c8df0965 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/loading_state.tsx @@ -0,0 +1,30 @@ +/* + * 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 { EuiPage, EuiPageBody, EuiPageContent, EuiSpacer, EuiLoadingContent } from '@elastic/eui'; + +import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { EngineOverviewHeader } from '../engine_overview_header'; + +import './empty_states.scss'; + +export const LoadingState: React.FC = () => { + return ( + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss new file mode 100644 index 00000000000000..2c7f7de6458e2a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.scss @@ -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. + */ + +/** + * Engine Overview + */ +.engineOverview { + width: 100%; + + &__body { + padding: $euiSize; + + @include euiBreakpoint('m', 'l', 'xl') { + padding: $euiSizeXL; + } + } +} + +.engineIcon { + display: inline-block; + width: $euiSize; + height: $euiSize; + margin-right: $euiSizeXS; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx new file mode 100644 index 00000000000000..4d2a2ea1df9aa9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx @@ -0,0 +1,171 @@ +/* + * 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 '../../../__mocks__/react_router_history.mock'; + +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { render, ReactWrapper } from 'enzyme'; + +import { I18nProvider } from '@kbn/i18n/react'; +import { KibanaContext } from '../../../'; +import { LicenseContext } from '../../../shared/licensing'; +import { mountWithContext, mockKibanaContext } from '../../../__mocks__'; + +import { EmptyState, ErrorState } from '../empty_states'; +import { EngineTable, IEngineTablePagination } from './engine_table'; + +import { EngineOverview } from './'; + +describe('EngineOverview', () => { + describe('non-happy-path states', () => { + it('isLoading', () => { + // We use render() instead of mount() here to not trigger lifecycle methods (i.e., useEffect) + // TODO: Consider pulling this out to a renderWithContext mock/helper + const wrapper: Cheerio = render( + + + + + + + + ); + + // render() directly renders HTML which means we have to look for selectors instead of for LoadingState directly + expect(wrapper.find('.euiLoadingContent')).toHaveLength(2); + }); + + it('isEmpty', async () => { + const wrapper = await mountWithApiMock({ + get: () => ({ + results: [], + meta: { page: { total_results: 0 } }, + }), + }); + + expect(wrapper.find(EmptyState)).toHaveLength(1); + }); + + it('hasErrorConnecting', async () => { + const wrapper = await mountWithApiMock({ + get: () => ({ invalidPayload: true }), + }); + expect(wrapper.find(ErrorState)).toHaveLength(1); + }); + }); + + describe('happy-path states', () => { + const mockedApiResponse = { + results: [ + { + name: 'hello-world', + created_at: 'Fri, 1 Jan 1970 12:00:00 +0000', + document_count: 50, + field_count: 10, + }, + ], + meta: { + page: { + current: 1, + total_pages: 10, + total_results: 100, + size: 10, + }, + }, + }; + const mockApi = jest.fn(() => mockedApiResponse); + let wrapper: ReactWrapper; + + beforeAll(async () => { + wrapper = await mountWithApiMock({ get: mockApi }); + }); + + it('renders', () => { + expect(wrapper.find(EngineTable)).toHaveLength(1); + }); + + it('calls the engines API', () => { + expect(mockApi).toHaveBeenNthCalledWith(1, '/api/app_search/engines', { + query: { + type: 'indexed', + pageIndex: 1, + }, + }); + }); + + describe('pagination', () => { + const getTablePagination: () => IEngineTablePagination = () => + wrapper.find(EngineTable).first().prop('pagination'); + + it('passes down page data from the API', () => { + const pagination = getTablePagination(); + + expect(pagination.totalEngines).toEqual(100); + expect(pagination.pageIndex).toEqual(0); + }); + + it('re-polls the API on page change', async () => { + await act(async () => getTablePagination().onPaginate(5)); + wrapper.update(); + + expect(mockApi).toHaveBeenLastCalledWith('/api/app_search/engines', { + query: { + type: 'indexed', + pageIndex: 5, + }, + }); + expect(getTablePagination().pageIndex).toEqual(4); + }); + }); + + describe('when on a platinum license', () => { + beforeAll(async () => { + mockApi.mockClear(); + wrapper = await mountWithApiMock({ + license: { type: 'platinum', isActive: true }, + get: mockApi, + }); + }); + + it('renders a 2nd meta engines table', () => { + expect(wrapper.find(EngineTable)).toHaveLength(2); + }); + + it('makes a 2nd call to the engines API with type meta', () => { + expect(mockApi).toHaveBeenNthCalledWith(2, '/api/app_search/engines', { + query: { + type: 'meta', + pageIndex: 1, + }, + }); + }); + }); + }); + + /** + * Test helpers + */ + + const mountWithApiMock = async ({ get, license }: { get(): any; license?: object }) => { + let wrapper: ReactWrapper | undefined; + const httpMock = { ...mockKibanaContext.http, get }; + + // We get a lot of act() warning/errors in the terminal without this. + // TBH, I don't fully understand why since Enzyme's mount is supposed to + // have act() baked in - could be because of the wrapping context provider? + await act(async () => { + wrapper = mountWithContext(, { http: httpMock, license }); + }); + if (wrapper) { + wrapper.update(); // This seems to be required for the DOM to actually update + + return wrapper; + } else { + throw new Error('Could not mount wrapper'); + } + }; +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx new file mode 100644 index 00000000000000..13d092a657d11c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -0,0 +1,155 @@ +/* + * 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, { useContext, useEffect, useState } from 'react'; +import { + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentHeader, + EuiPageContentBody, + EuiTitle, + EuiSpacer, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; +import { LicenseContext, ILicenseContext, hasPlatinumLicense } from '../../../shared/licensing'; +import { KibanaContext, IKibanaContext } from '../../../index'; + +import EnginesIcon from '../../assets/engine.svg'; +import MetaEnginesIcon from '../../assets/meta_engine.svg'; + +import { LoadingState, EmptyState, ErrorState } from '../empty_states'; +import { EngineOverviewHeader } from '../engine_overview_header'; +import { EngineTable } from './engine_table'; + +import './engine_overview.scss'; + +interface IGetEnginesParams { + type: string; + pageIndex: number; +} +interface ISetEnginesCallbacks { + setResults: React.Dispatch>; + setResultsTotal: React.Dispatch>; +} + +export const EngineOverview: React.FC = () => { + const { http } = useContext(KibanaContext) as IKibanaContext; + const { license } = useContext(LicenseContext) as ILicenseContext; + + const [isLoading, setIsLoading] = useState(true); + const [hasErrorConnecting, setHasErrorConnecting] = useState(false); + + const [engines, setEngines] = useState([]); + const [enginesPage, setEnginesPage] = useState(1); + const [enginesTotal, setEnginesTotal] = useState(0); + const [metaEngines, setMetaEngines] = useState([]); + const [metaEnginesPage, setMetaEnginesPage] = useState(1); + const [metaEnginesTotal, setMetaEnginesTotal] = useState(0); + + const getEnginesData = async ({ type, pageIndex }: IGetEnginesParams) => { + return await http.get('/api/app_search/engines', { + query: { type, pageIndex }, + }); + }; + const setEnginesData = async (params: IGetEnginesParams, callbacks: ISetEnginesCallbacks) => { + try { + const response = await getEnginesData(params); + + callbacks.setResults(response.results); + callbacks.setResultsTotal(response.meta.page.total_results); + + setIsLoading(false); + } catch (error) { + setHasErrorConnecting(true); + } + }; + + useEffect(() => { + const params = { type: 'indexed', pageIndex: enginesPage }; + const callbacks = { setResults: setEngines, setResultsTotal: setEnginesTotal }; + + setEnginesData(params, callbacks); + }, [enginesPage]); + + useEffect(() => { + if (hasPlatinumLicense(license)) { + const params = { type: 'meta', pageIndex: metaEnginesPage }; + const callbacks = { setResults: setMetaEngines, setResultsTotal: setMetaEnginesTotal }; + + setEnginesData(params, callbacks); + } + }, [license, metaEnginesPage]); + + if (hasErrorConnecting) return ; + if (isLoading) return ; + if (!engines.length) return ; + + return ( + + + + + + + + + + +

+ + +

+
+
+ + + + + {metaEngines.length > 0 && ( + <> + + + +

+ + +

+
+
+ + + + + )} +
+
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx new file mode 100644 index 00000000000000..46b6e61e352de5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx @@ -0,0 +1,80 @@ +/* + * 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 { EuiBasicTable, EuiPagination, EuiButtonEmpty, EuiLink } from '@elastic/eui'; + +import { mountWithContext } from '../../../__mocks__'; +jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() })); +import { sendTelemetry } from '../../../shared/telemetry'; + +import { EngineTable } from './engine_table'; + +describe('EngineTable', () => { + const onPaginate = jest.fn(); // onPaginate updates the engines API call upstream + + const wrapper = mountWithContext( + + ); + const table = wrapper.find(EuiBasicTable); + + it('renders', () => { + expect(table).toHaveLength(1); + expect(table.prop('pagination').totalItemCount).toEqual(50); + + const tableContent = table.text(); + expect(tableContent).toContain('test-engine'); + expect(tableContent).toContain('January 1, 1970'); + expect(tableContent).toContain('99,999'); + expect(tableContent).toContain('10'); + + expect(table.find(EuiPagination).find(EuiButtonEmpty)).toHaveLength(5); // Should display 5 pages at 10 engines per page + }); + + it('contains engine links which send telemetry', () => { + const engineLinks = wrapper.find(EuiLink); + + engineLinks.forEach((link) => { + expect(link.prop('href')).toEqual('http://localhost:3002/as/engines/test-engine'); + link.simulate('click'); + + expect(sendTelemetry).toHaveBeenCalledWith({ + http: expect.any(Object), + product: 'app_search', + action: 'clicked', + metric: 'engine_table_link', + }); + }); + }); + + it('triggers onPaginate', () => { + table.prop('onChange')({ page: { index: 4 } }); + + expect(onPaginate).toHaveBeenCalledWith(5); + }); + + it('handles empty data', () => { + const emptyWrapper = mountWithContext( + {} }} /> + ); + const emptyTable = emptyWrapper.find(EuiBasicTable); + expect(emptyTable.prop('pagination').pageIndex).toEqual(0); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx new file mode 100644 index 00000000000000..1e58d820dc83b0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; +import { EuiBasicTable, EuiBasicTableColumn, EuiLink } from '@elastic/eui'; +import { FormattedMessage, FormattedDate, FormattedNumber } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { sendTelemetry } from '../../../shared/telemetry'; +import { KibanaContext, IKibanaContext } from '../../../index'; + +import { ENGINES_PAGE_SIZE } from '../../../../../common/constants'; + +export interface IEngineTableData { + name: string; + created_at: string; + document_count: number; + field_count: number; +} +export interface IEngineTablePagination { + totalEngines: number; + pageIndex: number; + onPaginate(pageIndex: number): void; +} +export interface IEngineTableProps { + data: IEngineTableData[]; + pagination: IEngineTablePagination; +} +export interface IOnChange { + page: { + index: number; + }; +} + +export const EngineTable: React.FC = ({ + data, + pagination: { totalEngines, pageIndex, onPaginate }, +}) => { + const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext; + const engineLinkProps = (name: string) => ({ + href: `${enterpriseSearchUrl}/as/engines/${name}`, + target: '_blank', + onClick: () => + sendTelemetry({ + http, + product: 'app_search', + action: 'clicked', + metric: 'engine_table_link', + }), + }); + + const columns: Array> = [ + { + field: 'name', + name: i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.table.column.name', { + defaultMessage: 'Name', + }), + render: (name: string) => ( + + {name} + + ), + width: '30%', + truncateText: true, + mobileOptions: { + header: true, + // Note: the below props are valid props per https://elastic.github.io/eui/#/tabular-content/tables (Responsive tables), but EUI's types have a bug reporting it as an error + // @ts-ignore + enlarge: true, + fullWidth: true, + truncateText: false, + }, + }, + { + field: 'created_at', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.createdAt', + { + defaultMessage: 'Created At', + } + ), + dataType: 'string', + render: (dateString: string) => ( + // e.g., January 1, 1970 + + ), + }, + { + field: 'document_count', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.documentCount', + { + defaultMessage: 'Document Count', + } + ), + dataType: 'number', + render: (number: number) => , + truncateText: true, + }, + { + field: 'field_count', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.fieldCount', + { + defaultMessage: 'Field Count', + } + ), + dataType: 'number', + render: (number: number) => , + truncateText: true, + }, + { + field: 'name', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.actions', + { + defaultMessage: 'Actions', + } + ), + dataType: 'string', + render: (name: string) => ( + + + + ), + align: 'right', + width: '100px', + }, + ]; + + return ( + { + const { index } = page; + onPaginate(index + 1); // Note on paging - App Search's API pages start at 1, EuiBasicTables' pages start at 0 + }} + /> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/index.ts new file mode 100644 index 00000000000000..48b7645dc39e8b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/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 { EngineOverview } from './engine_overview'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx new file mode 100644 index 00000000000000..2e49540270ef07 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx @@ -0,0 +1,41 @@ +/* + * 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 '../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() })); +import { sendTelemetry } from '../../../shared/telemetry'; + +import { EngineOverviewHeader } from '../engine_overview_header'; + +describe('EngineOverviewHeader', () => { + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find('h1')).toHaveLength(1); + }); + + it('renders a launch app search button that sends telemetry on click', () => { + const wrapper = shallow(); + const button = wrapper.find('[data-test-subj="launchButton"]'); + + expect(button.prop('href')).toBe('http://localhost:3002/as'); + expect(button.prop('isDisabled')).toBeFalsy(); + + button.simulate('click'); + expect(sendTelemetry).toHaveBeenCalled(); + }); + + it('renders a disabled button when isButtonDisabled is true', () => { + const wrapper = shallow(); + const button = wrapper.find('[data-test-subj="launchButton"]'); + + expect(button.prop('isDisabled')).toBe(true); + expect(button.prop('href')).toBeUndefined(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx new file mode 100644 index 00000000000000..9aafa8ec0380c7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx @@ -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. + */ + +import React, { useContext } from 'react'; +import { + EuiPageHeader, + EuiPageHeaderSection, + EuiTitle, + EuiButton, + EuiButtonProps, + EuiLinkProps, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { sendTelemetry } from '../../../shared/telemetry'; +import { KibanaContext, IKibanaContext } from '../../../index'; + +interface IEngineOverviewHeaderProps { + isButtonDisabled?: boolean; +} + +export const EngineOverviewHeader: React.FC = ({ + isButtonDisabled, +}) => { + const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext; + + const buttonProps = { + fill: true, + iconType: 'popout', + 'data-test-subj': 'launchButton', + } as EuiButtonProps & EuiLinkProps; + + if (isButtonDisabled) { + buttonProps.isDisabled = true; + } else { + buttonProps.href = `${enterpriseSearchUrl}/as`; + buttonProps.target = '_blank'; + buttonProps.onClick = () => + sendTelemetry({ + http, + product: 'app_search', + action: 'clicked', + metric: 'header_launch_button', + }); + } + + return ( + + + +

+ +

+
+
+ + + + + +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/index.ts new file mode 100644 index 00000000000000..2d37f037e21e5c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/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 { EngineOverviewHeader } from './engine_overview_header'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/index.ts new file mode 100644 index 00000000000000..c367424d375f9d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/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 { SetupGuide } from './setup_guide'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx new file mode 100644 index 00000000000000..82cc344d496322 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx @@ -0,0 +1,21 @@ +/* + * 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 { shallow } from 'enzyme'; + +import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SetupGuide as SetupGuideLayout } from '../../../shared/setup_guide'; +import { SetupGuide } from './'; + +describe('SetupGuide', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(SetupGuideLayout)).toHaveLength(1); + expect(wrapper.find(SetBreadcrumbs)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx new file mode 100644 index 00000000000000..df278bf938a690 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx @@ -0,0 +1,64 @@ +/* + * 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 { EuiSpacer, EuiTitle, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { SetupGuide as SetupGuideLayout } from '../../../shared/setup_guide'; +import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; +import GettingStarted from '../../assets/getting_started.png'; + +export const SetupGuide: React.FC = () => ( + + + + +
+ {i18n.translate('xpack.enterpriseSearch.appSearch.setupGuide.videoAlt', + + + +

+ +

+
+ + +

+ +

+
+ +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx new file mode 100644 index 00000000000000..45e318ca0f9d95 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -0,0 +1,46 @@ +/* + * 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 '../__mocks__/shallow_usecontext.mock'; + +import React, { useContext } from 'react'; +import { Redirect } from 'react-router-dom'; +import { shallow } from 'enzyme'; + +import { SetupGuide } from './components/setup_guide'; +import { EngineOverview } from './components/engine_overview'; + +import { AppSearch } from './'; + +describe('App Search Routes', () => { + describe('/', () => { + it('redirects to Setup Guide when enterpriseSearchUrl is not set', () => { + (useContext as jest.Mock).mockImplementationOnce(() => ({ enterpriseSearchUrl: '' })); + const wrapper = shallow(); + + expect(wrapper.find(Redirect)).toHaveLength(1); + expect(wrapper.find(EngineOverview)).toHaveLength(0); + }); + + it('renders Engine Overview when enterpriseSearchUrl is set', () => { + (useContext as jest.Mock).mockImplementationOnce(() => ({ + enterpriseSearchUrl: 'https://foo.bar', + })); + const wrapper = shallow(); + + expect(wrapper.find(EngineOverview)).toHaveLength(1); + expect(wrapper.find(Redirect)).toHaveLength(0); + }); + }); + + describe('/setup_guide', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(SetupGuide)).toHaveLength(1); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx new file mode 100644 index 00000000000000..8f7142f1631a95 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -0,0 +1,28 @@ +/* + * 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, { useContext } from 'react'; +import { Route, Redirect } from 'react-router-dom'; + +import { KibanaContext, IKibanaContext } from '../index'; + +import { SetupGuide } from './components/setup_guide'; +import { EngineOverview } from './components/engine_overview'; + +export const AppSearch: React.FC = () => { + const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; + + return ( + <> + + {!enterpriseSearchUrl ? : } + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx new file mode 100644 index 00000000000000..1aead8468ca3b0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { coreMock } from 'src/core/public/mocks'; +import { licensingMock } from '../../../licensing/public/mocks'; + +import { renderApp } from './'; +import { AppSearch } from './app_search'; + +describe('renderApp', () => { + const params = coreMock.createAppMountParamters(); + const core = coreMock.createStart(); + const config = {}; + const plugins = { + licensing: licensingMock.createSetup(), + } as any; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('mounts and unmounts UI', () => { + const MockApp = () =>
Hello world!
; + + const unmount = renderApp(MockApp, core, params, config, plugins); + expect(params.element.querySelector('.hello-world')).not.toBeNull(); + unmount(); + expect(params.element.innerHTML).toEqual(''); + }); + + it('renders AppSearch', () => { + renderApp(AppSearch, core, params, config, plugins); + expect(params.element.querySelector('.setupGuide')).not.toBeNull(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx new file mode 100644 index 00000000000000..4ef7aca8260a20 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx @@ -0,0 +1,56 @@ +/* + * 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 ReactDOM from 'react-dom'; +import { Router } from 'react-router-dom'; + +import { I18nProvider } from '@kbn/i18n/react'; +import { CoreStart, AppMountParameters, HttpSetup, ChromeBreadcrumb } from 'src/core/public'; +import { ClientConfigType, PluginsSetup } from '../plugin'; +import { LicenseProvider } from './shared/licensing'; + +export interface IKibanaContext { + enterpriseSearchUrl?: string; + http: HttpSetup; + setBreadcrumbs(crumbs: ChromeBreadcrumb[]): void; +} + +export const KibanaContext = React.createContext({}); + +/** + * This file serves as a reusable wrapper to share Kibana-level context and other helpers + * between various Enterprise Search plugins (e.g. AppSearch, WorkplaceSearch, ES landing page) + * which should be imported and passed in as the first param in plugin.ts. + */ + +export const renderApp = ( + App: React.FC, + core: CoreStart, + params: AppMountParameters, + config: ClientConfigType, + plugins: PluginsSetup +) => { + ReactDOM.render( + + + + + + + + + , + params.element + ); + return () => ReactDOM.unmountComponentAtNode(params.element); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.test.ts new file mode 100644 index 00000000000000..42f308c5542688 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.test.ts @@ -0,0 +1,30 @@ +/* + * 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 { getPublicUrl } from './'; + +describe('Enterprise Search URL helper', () => { + const httpMock = { get: jest.fn() } as any; + + it('calls and returns the public URL API endpoint', async () => { + httpMock.get.mockImplementationOnce(() => ({ publicUrl: 'http://some.vanity.url' })); + + expect(await getPublicUrl(httpMock)).toEqual('http://some.vanity.url'); + }); + + it('strips trailing slashes', async () => { + httpMock.get.mockImplementationOnce(() => ({ publicUrl: 'http://trailing.slash/' })); + + expect(await getPublicUrl(httpMock)).toEqual('http://trailing.slash'); + }); + + // For the most part, error logging/handling is done on the server side. + // On the front-end, we should simply gracefully fall back to config.host + // if we can't fetch a public URL + it('falls back to an empty string', async () => { + expect(await getPublicUrl(httpMock)).toEqual(''); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.ts b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.ts new file mode 100644 index 00000000000000..419c187a0048a9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/get_enterprise_search_url.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 { HttpSetup } from 'src/core/public'; + +/** + * On Elastic Cloud, the host URL set in kibana.yml is not necessarily the same + * URL we want to send users to in the front-end (e.g. if a vanity URL is set). + * + * This helper checks a Kibana API endpoint (which has checks an Enterprise + * Search internal API endpoint) for the correct public-facing URL to use. + */ +export const getPublicUrl = async (http: HttpSetup): Promise => { + try { + const { publicUrl } = await http.get('/api/enterprise_search/public_url'); + return stripTrailingSlash(publicUrl); + } catch { + return ''; + } +}; + +const stripTrailingSlash = (url: string): string => { + return url.endsWith('/') ? url.slice(0, -1) : url; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/index.ts new file mode 100644 index 00000000000000..bbbb688b8ea7b4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/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 { getPublicUrl } from './get_enterprise_search_url'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts new file mode 100644 index 00000000000000..7ea73577c4de6f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts @@ -0,0 +1,206 @@ +/* + * 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 { generateBreadcrumb } from './generate_breadcrumbs'; +import { appSearchBreadcrumbs, enterpriseSearchBreadcrumbs } from './'; + +import { mockHistory as mockHistoryUntyped } from '../../__mocks__'; +const mockHistory = mockHistoryUntyped as any; + +jest.mock('../react_router_helpers', () => ({ letBrowserHandleEvent: jest.fn(() => false) })); +import { letBrowserHandleEvent } from '../react_router_helpers'; + +describe('generateBreadcrumb', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("creates a breadcrumb object matching EUI's breadcrumb type", () => { + const breadcrumb = generateBreadcrumb({ + text: 'Hello World', + path: '/hello_world', + history: mockHistory, + }); + expect(breadcrumb).toEqual({ + text: 'Hello World', + href: '/enterprise_search/hello_world', + onClick: expect.any(Function), + }); + }); + + it('prevents default navigation and uses React Router history on click', () => { + const breadcrumb = generateBreadcrumb({ text: '', path: '/', history: mockHistory }) as any; + const event = { preventDefault: jest.fn() }; + breadcrumb.onClick(event); + + expect(mockHistory.push).toHaveBeenCalled(); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('does not prevent default browser behavior on new tab/window clicks', () => { + const breadcrumb = generateBreadcrumb({ text: '', path: '/', history: mockHistory }) as any; + + (letBrowserHandleEvent as jest.Mock).mockImplementationOnce(() => true); + breadcrumb.onClick(); + + expect(mockHistory.push).not.toHaveBeenCalled(); + }); + + it('does not generate link behavior if path is excluded', () => { + const breadcrumb = generateBreadcrumb({ text: 'Unclickable breadcrumb' }); + + expect(breadcrumb.href).toBeUndefined(); + expect(breadcrumb.onClick).toBeUndefined(); + }); +}); + +describe('enterpriseSearchBreadcrumbs', () => { + const breadCrumbs = [ + { + text: 'Page 1', + path: '/page1', + }, + { + text: 'Page 2', + path: '/page2', + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const subject = () => enterpriseSearchBreadcrumbs(mockHistory)(breadCrumbs); + + it('Builds a chain of breadcrumbs with Enterprise Search at the root', () => { + expect(subject()).toEqual([ + { + text: 'Enterprise Search', + }, + { + href: '/enterprise_search/page1', + onClick: expect.any(Function), + text: 'Page 1', + }, + { + href: '/enterprise_search/page2', + onClick: expect.any(Function), + text: 'Page 2', + }, + ]); + }); + + it('shows just the root if breadcrumbs is empty', () => { + expect(enterpriseSearchBreadcrumbs(mockHistory)()).toEqual([ + { + text: 'Enterprise Search', + }, + ]); + }); + + describe('links', () => { + const eventMock = { + preventDefault: jest.fn(), + } as any; + + it('has Enterprise Search text first', () => { + expect(subject()[0].onClick).toBeUndefined(); + }); + + it('has a link to page 1 second', () => { + (subject()[1] as any).onClick(eventMock); + expect(mockHistory.push).toHaveBeenCalledWith('/page1'); + }); + + it('has a link to page 2 last', () => { + (subject()[2] as any).onClick(eventMock); + expect(mockHistory.push).toHaveBeenCalledWith('/page2'); + }); + }); +}); + +describe('appSearchBreadcrumbs', () => { + const breadCrumbs = [ + { + text: 'Page 1', + path: '/page1', + }, + { + text: 'Page 2', + path: '/page2', + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + mockHistory.createHref.mockImplementation( + ({ pathname }: any) => `/enterprise_search/app_search${pathname}` + ); + }); + + const subject = () => appSearchBreadcrumbs(mockHistory)(breadCrumbs); + + it('Builds a chain of breadcrumbs with Enterprise Search and App Search at the root', () => { + expect(subject()).toEqual([ + { + text: 'Enterprise Search', + }, + { + href: '/enterprise_search/app_search/', + onClick: expect.any(Function), + text: 'App Search', + }, + { + href: '/enterprise_search/app_search/page1', + onClick: expect.any(Function), + text: 'Page 1', + }, + { + href: '/enterprise_search/app_search/page2', + onClick: expect.any(Function), + text: 'Page 2', + }, + ]); + }); + + it('shows just the root if breadcrumbs is empty', () => { + expect(appSearchBreadcrumbs(mockHistory)()).toEqual([ + { + text: 'Enterprise Search', + }, + { + href: '/enterprise_search/app_search/', + onClick: expect.any(Function), + text: 'App Search', + }, + ]); + }); + + describe('links', () => { + const eventMock = { + preventDefault: jest.fn(), + } as any; + + it('has Enterprise Search text first', () => { + expect(subject()[0].onClick).toBeUndefined(); + }); + + it('has a link to App Search second', () => { + (subject()[1] as any).onClick(eventMock); + expect(mockHistory.push).toHaveBeenCalledWith('/'); + }); + + it('has a link to page 1 third', () => { + (subject()[2] as any).onClick(eventMock); + expect(mockHistory.push).toHaveBeenCalledWith('/page1'); + }); + + it('has a link to page 2 last', () => { + (subject()[3] as any).onClick(eventMock); + expect(mockHistory.push).toHaveBeenCalledWith('/page2'); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts new file mode 100644 index 00000000000000..0e1bb796cbf2ec --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts @@ -0,0 +1,54 @@ +/* + * 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 { Breadcrumb as EuiBreadcrumb } from '@elastic/eui'; +import { History } from 'history'; + +import { letBrowserHandleEvent } from '../react_router_helpers'; + +/** + * Generate React-Router-friendly EUI breadcrumb objects + * https://elastic.github.io/eui/#/navigation/breadcrumbs + */ + +interface IGenerateBreadcrumbProps { + text: string; + path?: string; + history?: History; +} + +export const generateBreadcrumb = ({ text, path, history }: IGenerateBreadcrumbProps) => { + const breadcrumb = { text } as EuiBreadcrumb; + + if (path && history) { + breadcrumb.href = history.createHref({ pathname: path }); + breadcrumb.onClick = (event) => { + if (letBrowserHandleEvent(event)) return; + event.preventDefault(); + history.push(path); + }; + } + + return breadcrumb; +}; + +/** + * Product-specific breadcrumb helpers + */ + +export type TBreadcrumbs = IGenerateBreadcrumbProps[]; + +export const enterpriseSearchBreadcrumbs = (history: History) => ( + breadcrumbs: TBreadcrumbs = [] +) => [ + generateBreadcrumb({ text: 'Enterprise Search' }), + ...breadcrumbs.map(({ text, path }: IGenerateBreadcrumbProps) => + generateBreadcrumb({ text, path, history }) + ), +]; + +export const appSearchBreadcrumbs = (history: History) => (breadcrumbs: TBreadcrumbs = []) => + enterpriseSearchBreadcrumbs(history)([{ text: 'App Search', path: '/' }, ...breadcrumbs]); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts new file mode 100644 index 00000000000000..cf8bbbc593f2f7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { enterpriseSearchBreadcrumbs } from './generate_breadcrumbs'; +export { appSearchBreadcrumbs } from './generate_breadcrumbs'; +export { SetAppSearchBreadcrumbs } from './set_breadcrumbs'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx new file mode 100644 index 00000000000000..974ca54277c51c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx @@ -0,0 +1,63 @@ +/* + * 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 '../../__mocks__/react_router_history.mock'; +import { mountWithKibanaContext } from '../../__mocks__'; + +jest.mock('./generate_breadcrumbs', () => ({ appSearchBreadcrumbs: jest.fn() })); +import { appSearchBreadcrumbs, SetAppSearchBreadcrumbs } from './'; + +describe('SetAppSearchBreadcrumbs', () => { + const setBreadcrumbs = jest.fn(); + const builtBreadcrumbs = [] as any; + const appSearchBreadCrumbsInnerCall = jest.fn().mockReturnValue(builtBreadcrumbs); + const appSearchBreadCrumbsOuterCall = jest.fn().mockReturnValue(appSearchBreadCrumbsInnerCall); + (appSearchBreadcrumbs as jest.Mock).mockImplementation(appSearchBreadCrumbsOuterCall); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const mountSetAppSearchBreadcrumbs = (props: any) => { + return mountWithKibanaContext(, { + http: {}, + enterpriseSearchUrl: 'http://localhost:3002', + setBreadcrumbs, + }); + }; + + describe('when isRoot is false', () => { + const subject = () => mountSetAppSearchBreadcrumbs({ text: 'Page 1', isRoot: false }); + + it('calls appSearchBreadcrumbs to build breadcrumbs, then registers them with Kibana', () => { + subject(); + + // calls appSearchBreadcrumbs to build breadcrumbs with the target page and current location + expect(appSearchBreadCrumbsInnerCall).toHaveBeenCalledWith([ + { text: 'Page 1', path: '/current-path' }, + ]); + + // then registers them with Kibana + expect(setBreadcrumbs).toHaveBeenCalledWith(builtBreadcrumbs); + }); + }); + + describe('when isRoot is true', () => { + const subject = () => mountSetAppSearchBreadcrumbs({ text: 'Page 1', isRoot: true }); + + it('calls appSearchBreadcrumbs to build breadcrumbs with an empty breadcrumb, then registers them with Kibana', () => { + subject(); + + // uses an empty bredcrumb + expect(appSearchBreadCrumbsInnerCall).toHaveBeenCalledWith([]); + + // then registers them with Kibana + expect(setBreadcrumbs).toHaveBeenCalledWith(builtBreadcrumbs); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx new file mode 100644 index 00000000000000..ad3cd65c09516b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import { Breadcrumb as EuiBreadcrumb } from '@elastic/eui'; +import { KibanaContext, IKibanaContext } from '../../index'; +import { appSearchBreadcrumbs, TBreadcrumbs } from './generate_breadcrumbs'; + +/** + * Small on-mount helper for setting Kibana's chrome breadcrumbs on any App Search view + * @see https://github.com/elastic/kibana/blob/master/src/core/public/chrome/chrome_service.tsx + */ + +export type TSetBreadcrumbs = (breadcrumbs: EuiBreadcrumb[]) => void; + +interface IBreadcrumbProps { + text: string; + isRoot?: never; +} +interface IRootBreadcrumbProps { + isRoot: true; + text?: never; +} + +export const SetAppSearchBreadcrumbs: React.FC = ({ + text, + isRoot, +}) => { + const history = useHistory(); + const { setBreadcrumbs } = useContext(KibanaContext) as IKibanaContext; + + const crumb = isRoot ? [] : [{ text, path: history.location.pathname }]; + + useEffect(() => { + setBreadcrumbs(appSearchBreadcrumbs(history)(crumb as TBreadcrumbs | [])); + }, []); + + return null; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts new file mode 100644 index 00000000000000..9c8c1417d48db2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { LicenseContext, LicenseProvider, ILicenseContext } from './license_context'; +export { hasPlatinumLicense } from './license_checks'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.test.ts new file mode 100644 index 00000000000000..ad134e7d36b10c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.test.ts @@ -0,0 +1,33 @@ +/* + * 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 { hasPlatinumLicense } from './license_checks'; + +describe('hasPlatinumLicense', () => { + it('is true for platinum licenses', () => { + expect(hasPlatinumLicense({ isActive: true, type: 'platinum' } as any)).toEqual(true); + }); + + it('is true for enterprise licenses', () => { + expect(hasPlatinumLicense({ isActive: true, type: 'enterprise' } as any)).toEqual(true); + }); + + it('is true for trial licenses', () => { + expect(hasPlatinumLicense({ isActive: true, type: 'platinum' } as any)).toEqual(true); + }); + + it('is false if the current license is expired', () => { + expect(hasPlatinumLicense({ isActive: false, type: 'platinum' } as any)).toEqual(false); + expect(hasPlatinumLicense({ isActive: false, type: 'enterprise' } as any)).toEqual(false); + expect(hasPlatinumLicense({ isActive: false, type: 'trial' } as any)).toEqual(false); + }); + + it('is false for licenses below platinum', () => { + expect(hasPlatinumLicense({ isActive: true, type: 'basic' } as any)).toEqual(false); + expect(hasPlatinumLicense({ isActive: false, type: 'standard' } as any)).toEqual(false); + expect(hasPlatinumLicense({ isActive: true, type: 'gold' } as any)).toEqual(false); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts new file mode 100644 index 00000000000000..de4a17ce2bd3c1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_checks.ts @@ -0,0 +1,11 @@ +/* + * 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 { ILicense } from '../../../../../licensing/public'; + +export const hasPlatinumLicense = (license?: ILicense) => { + return license?.isActive && ['platinum', 'enterprise', 'trial'].includes(license?.type as string); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx new file mode 100644 index 00000000000000..c65474ec1f5900 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.test.tsx @@ -0,0 +1,24 @@ +/* + * 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, { useContext } from 'react'; + +import { mountWithContext } from '../../__mocks__'; +import { LicenseContext, ILicenseContext } from './'; + +describe('LicenseProvider', () => { + const MockComponent: React.FC = () => { + const { license } = useContext(LicenseContext) as ILicenseContext; + return
{license?.type}
; + }; + + it('renders children', () => { + const wrapper = mountWithContext(, { license: { type: 'basic' } }); + + expect(wrapper.find('.license-test')).toHaveLength(1); + expect(wrapper.text()).toEqual('basic'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.tsx new file mode 100644 index 00000000000000..9b47959ff75447 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/license_context.tsx @@ -0,0 +1,29 @@ +/* + * 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 useObservable from 'react-use/lib/useObservable'; +import { Observable } from 'rxjs'; + +import { ILicense } from '../../../../../licensing/public'; + +export interface ILicenseContext { + license: ILicense; +} +interface ILicenseContextProps { + license$: Observable; + children: React.ReactNode; +} + +export const LicenseContext = React.createContext({}); + +export const LicenseProvider: React.FC = ({ license$, children }) => { + // Listen for changes to license subscription + const license = useObservable(license$); + + // Render rest of application and pass down license via context + return ; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx new file mode 100644 index 00000000000000..7d4c068b211555 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx @@ -0,0 +1,77 @@ +/* + * 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 { shallow, mount } from 'enzyme'; +import { EuiLink, EuiButton } from '@elastic/eui'; + +import '../../__mocks__/react_router_history.mock'; +import { mockHistory } from '../../__mocks__'; + +import { EuiReactRouterLink, EuiReactRouterButton } from './eui_link'; + +describe('EUI & React Router Component Helpers', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiLink)).toHaveLength(1); + }); + + it('renders an EuiButton', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiButton)).toHaveLength(1); + }); + + it('passes down all ...rest props', () => { + const wrapper = shallow(); + const link = wrapper.find(EuiLink); + + expect(link.prop('external')).toEqual(true); + expect(link.prop('data-test-subj')).toEqual('foo'); + }); + + it('renders with the correct href and onClick props', () => { + const wrapper = mount(); + const link = wrapper.find(EuiLink); + + expect(link.prop('onClick')).toBeInstanceOf(Function); + expect(link.prop('href')).toEqual('/enterprise_search/foo/bar'); + expect(mockHistory.createHref).toHaveBeenCalled(); + }); + + describe('onClick', () => { + it('prevents default navigation and uses React Router history', () => { + const wrapper = mount(); + + const simulatedEvent = { + button: 0, + target: { getAttribute: () => '_self' }, + preventDefault: jest.fn(), + }; + wrapper.find(EuiLink).simulate('click', simulatedEvent); + + expect(simulatedEvent.preventDefault).toHaveBeenCalled(); + expect(mockHistory.push).toHaveBeenCalled(); + }); + + it('does not prevent default browser behavior on new tab/window clicks', () => { + const wrapper = mount(); + + const simulatedEvent = { + shiftKey: true, + target: { getAttribute: () => '_blank' }, + }; + wrapper.find(EuiLink).simulate('click', simulatedEvent); + + expect(mockHistory.push).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx new file mode 100644 index 00000000000000..f486e432bae76a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx @@ -0,0 +1,57 @@ +/* + * 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 { useHistory } from 'react-router-dom'; +import { EuiLink, EuiButton, EuiButtonProps, EuiLinkAnchorProps } from '@elastic/eui'; + +import { letBrowserHandleEvent } from './link_events'; + +/** + * Generates either an EuiLink or EuiButton with a React-Router-ified link + * + * Based off of EUI's recommendations for handling React Router: + * https://github.com/elastic/eui/blob/master/wiki/react-router.md#react-router-51 + */ + +interface IEuiReactRouterProps { + to: string; +} + +export const EuiReactRouterHelper: React.FC = ({ to, children }) => { + const history = useHistory(); + + const onClick = (event: React.MouseEvent) => { + if (letBrowserHandleEvent(event)) return; + + // Prevent regular link behavior, which causes a browser refresh. + event.preventDefault(); + + // Push the route to the history. + history.push(to); + }; + + // Generate the correct link href (with basename etc. accounted for) + const href = history.createHref({ pathname: to }); + + const reactRouterProps = { href, onClick }; + return React.cloneElement(children as React.ReactElement, reactRouterProps); +}; + +type TEuiReactRouterLinkProps = EuiLinkAnchorProps & IEuiReactRouterProps; +type TEuiReactRouterButtonProps = EuiButtonProps & IEuiReactRouterProps; + +export const EuiReactRouterLink: React.FC = ({ to, ...rest }) => ( + + + +); + +export const EuiReactRouterButton: React.FC = ({ to, ...rest }) => ( + + + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts new file mode 100644 index 00000000000000..46dc3286331533 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { letBrowserHandleEvent } from './link_events'; +export { EuiReactRouterLink as EuiLink } from './eui_link'; +export { EuiReactRouterButton as EuiButton } from './eui_link'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts new file mode 100644 index 00000000000000..3682946b63a136 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.test.ts @@ -0,0 +1,102 @@ +/* + * 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 { letBrowserHandleEvent } from '../react_router_helpers'; + +describe('letBrowserHandleEvent', () => { + const event = { + defaultPrevented: false, + metaKey: false, + altKey: false, + ctrlKey: false, + shiftKey: false, + button: 0, + target: { + getAttribute: () => '_self', + }, + } as any; + + describe('the browser should handle the link when', () => { + it('default is prevented', () => { + expect(letBrowserHandleEvent({ ...event, defaultPrevented: true })).toBe(true); + }); + + it('is modified with metaKey', () => { + expect(letBrowserHandleEvent({ ...event, metaKey: true })).toBe(true); + }); + + it('is modified with altKey', () => { + expect(letBrowserHandleEvent({ ...event, altKey: true })).toBe(true); + }); + + it('is modified with ctrlKey', () => { + expect(letBrowserHandleEvent({ ...event, ctrlKey: true })).toBe(true); + }); + + it('is modified with shiftKey', () => { + expect(letBrowserHandleEvent({ ...event, shiftKey: true })).toBe(true); + }); + + it('it is not a left click event', () => { + expect(letBrowserHandleEvent({ ...event, button: 2 })).toBe(true); + }); + + it('the target is anything value other than _self', () => { + expect( + letBrowserHandleEvent({ + ...event, + target: targetValue('_blank'), + }) + ).toBe(true); + }); + }); + + describe('the browser should NOT handle the link when', () => { + it('default is not prevented', () => { + expect(letBrowserHandleEvent({ ...event, defaultPrevented: false })).toBe(false); + }); + + it('is not modified', () => { + expect( + letBrowserHandleEvent({ + ...event, + metaKey: false, + altKey: false, + ctrlKey: false, + shiftKey: false, + }) + ).toBe(false); + }); + + it('it is a left click event', () => { + expect(letBrowserHandleEvent({ ...event, button: 0 })).toBe(false); + }); + + it('the target is a value of _self', () => { + expect( + letBrowserHandleEvent({ + ...event, + target: targetValue('_self'), + }) + ).toBe(false); + }); + + it('the target has no value', () => { + expect( + letBrowserHandleEvent({ + ...event, + target: targetValue(null), + }) + ).toBe(false); + }); + }); +}); + +const targetValue = (value: string | null) => { + return { + getAttribute: () => value, + }; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.ts new file mode 100644 index 00000000000000..93da2ab71d9527 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/link_events.ts @@ -0,0 +1,31 @@ +/* + * 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 { MouseEvent } from 'react'; + +/** + * Helper functions for determining which events we should + * let browsers handle natively, e.g. new tabs/windows + */ + +type THandleEvent = (event: MouseEvent) => boolean; + +export const letBrowserHandleEvent: THandleEvent = (event) => + event.defaultPrevented || + isModifiedEvent(event) || + !isLeftClickEvent(event) || + isTargetBlank(event); + +const isModifiedEvent: THandleEvent = (event) => + !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); + +const isLeftClickEvent: THandleEvent = (event) => event.button === 0; + +const isTargetBlank: THandleEvent = (event) => { + const element = event.target as HTMLElement; + const target = element.getAttribute('target'); + return !!target && target !== '_self'; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/index.ts new file mode 100644 index 00000000000000..c367424d375f9d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/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 { SetupGuide } from './setup_guide'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.scss b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.scss new file mode 100644 index 00000000000000..ecfa13cc828f0a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.scss @@ -0,0 +1,51 @@ +/* + * 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. + */ + +/** + * Setup Guide + */ +.setupGuide { + padding: 0; + min-height: 100vh; + + &__sidebar { + flex-basis: $euiSizeXXL * 7.5; + flex-shrink: 0; + padding: $euiSizeL; + margin-right: 0; + + background-color: $euiColorLightestShade; + border-color: $euiBorderColor; + border-style: solid; + border-width: 0 0 $euiBorderWidthThin 0; // bottom - mobile view + + @include euiBreakpoint('m', 'l', 'xl') { + border-width: 0 $euiBorderWidthThin 0 0; // right - desktop view + } + @include euiBreakpoint('m', 'l') { + flex-basis: $euiSizeXXL * 10; + } + @include euiBreakpoint('xl') { + flex-basis: $euiSizeXXL * 12.5; + } + } + + &__body { + align-self: start; + padding: $euiSizeL; + + @include euiBreakpoint('l') { + padding: $euiSizeXXL ($euiSizeXXL * 1.25); + } + } + + &__thumbnail { + display: block; + max-width: 100%; + height: auto; + margin: $euiSizeL auto; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.test.tsx new file mode 100644 index 00000000000000..0423ae61779af1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.test.tsx @@ -0,0 +1,44 @@ +/* + * 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 { shallow } from 'enzyme'; +import { EuiSteps, EuiIcon, EuiLink } from '@elastic/eui'; + +import { mountWithContext } from '../../__mocks__'; + +import { SetupGuide } from './'; + +describe('SetupGuide', () => { + it('renders', () => { + const wrapper = shallow( + +

Wow!

+
+ ); + + expect(wrapper.find('h1').text()).toEqual('Enterprise Search'); + expect(wrapper.find(EuiIcon).prop('type')).toEqual('logoEnterpriseSearch'); + expect(wrapper.find('[data-test-subj="test"]').text()).toEqual('Wow!'); + expect(wrapper.find(EuiSteps)).toHaveLength(1); + }); + + it('renders with optional auth links', () => { + const wrapper = mountWithContext( + + Baz + + ); + + expect(wrapper.find(EuiLink).first().prop('href')).toEqual('http://bar.com'); + expect(wrapper.find(EuiLink).last().prop('href')).toEqual('http://foo.com'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx new file mode 100644 index 00000000000000..31ff0089dbd7c9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx @@ -0,0 +1,226 @@ +/* + * 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 { + EuiPage, + EuiPageSideBar, + EuiPageBody, + EuiPageContent, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiText, + EuiIcon, + EuiSteps, + EuiCode, + EuiCodeBlock, + EuiAccordion, + EuiLink, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import './setup_guide.scss'; + +/** + * Shared Setup Guide component. Sidebar content and product name/links are + * customizable, but the basic layout and instruction steps are DRYed out + */ + +interface ISetupGuideProps { + children: React.ReactNode; + productName: string; + productEuiIcon: 'logoAppSearch' | 'logoWorkplaceSearch' | 'logoEnterpriseSearch'; + standardAuthLink?: string; + elasticsearchNativeAuthLink?: string; +} + +export const SetupGuide: React.FC = ({ + children, + productName, + productEuiIcon, + standardAuthLink, + elasticsearchNativeAuthLink, +}) => ( + + + + + + + + + + + + + + + +

{productName}

+
+
+
+ + {children} +
+ + + + +

+ config/kibana.yml, + configSetting: enterpriseSearch.host, + }} + /> +

+ + enterpriseSearch.host: 'http://localhost:3002' + + + ), + }, + { + title: i18n.translate('xpack.enterpriseSearch.setupGuide.step2.title', { + defaultMessage: 'Reload your Kibana instance', + }), + children: ( + +

+ +

+

+ + Elasticsearch Native Auth + + ) : ( + 'Elasticsearch Native Auth' + ), + }} + /> +

+
+ ), + }, + { + title: i18n.translate('xpack.enterpriseSearch.setupGuide.step3.title', { + defaultMessage: 'Troubleshooting issues', + }), + children: ( + <> + + +

+ +

+
+
+ + + +

+ +

+
+
+ + + +

+ + Standard Auth + + ) : ( + 'Standard Auth' + ), + }} + /> +

+
+
+ + ), + }, + ]} + /> +
+
+
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts new file mode 100644 index 00000000000000..f871f48b171548 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { sendTelemetry } from './send_telemetry'; +export { SendAppSearchTelemetry } from './send_telemetry'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx new file mode 100644 index 00000000000000..9825c0d8ab889d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx @@ -0,0 +1,56 @@ +/* + * 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 { httpServiceMock } from 'src/core/public/mocks'; +import { mountWithKibanaContext } from '../../__mocks__'; +import { sendTelemetry, SendAppSearchTelemetry } from './'; + +describe('Shared Telemetry Helpers', () => { + const httpMock = httpServiceMock.createSetupContract(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('sendTelemetry', () => { + it('successfully calls the server-side telemetry endpoint', () => { + sendTelemetry({ + http: httpMock, + product: 'enterprise_search', + action: 'viewed', + metric: 'setup_guide', + }); + + expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', { + headers: { 'Content-Type': 'application/json' }, + body: '{"action":"viewed","metric":"setup_guide"}', + }); + }); + + it('throws an error if the telemetry endpoint fails', () => { + const httpRejectMock = sendTelemetry({ + http: { put: () => Promise.reject() }, + } as any); + + expect(httpRejectMock).rejects.toThrow('Unable to send telemetry'); + }); + }); + + describe('React component helpers', () => { + it('SendAppSearchTelemetry component', () => { + mountWithKibanaContext(, { + http: httpMock, + }); + + expect(httpMock.put).toHaveBeenCalledWith('/api/app_search/telemetry', { + headers: { 'Content-Type': 'application/json' }, + body: '{"action":"clicked","metric":"button"}', + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx new file mode 100644 index 00000000000000..300cb182727174 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx @@ -0,0 +1,50 @@ +/* + * 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, { useContext, useEffect } from 'react'; + +import { HttpSetup } from 'src/core/public'; +import { KibanaContext, IKibanaContext } from '../../index'; + +interface ISendTelemetryProps { + action: 'viewed' | 'error' | 'clicked'; + metric: string; // e.g., 'setup_guide' +} + +interface ISendTelemetry extends ISendTelemetryProps { + http: HttpSetup; + product: 'app_search' | 'workplace_search' | 'enterprise_search'; +} + +/** + * Base function - useful for non-component actions, e.g. clicks + */ + +export const sendTelemetry = async ({ http, product, action, metric }: ISendTelemetry) => { + try { + await http.put(`/api/${product}/telemetry`, { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action, metric }), + }); + } catch (error) { + throw new Error('Unable to send telemetry'); + } +}; + +/** + * React component helpers - useful for on-page-load/views + * TODO: SendWorkplaceSearchTelemetry and SendEnterpriseSearchTelemetry + */ + +export const SendAppSearchTelemetry: React.FC = ({ action, metric }) => { + const { http } = useContext(KibanaContext) as IKibanaContext; + + useEffect(() => { + sendTelemetry({ http, action, metric, product: 'app_search' }); + }, [action, metric, http]); + + return null; +}; diff --git a/x-pack/plugins/enterprise_search/public/index.ts b/x-pack/plugins/enterprise_search/public/index.ts new file mode 100644 index 00000000000000..06272641b19294 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/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 { PluginInitializerContext } from 'src/core/public'; +import { EnterpriseSearchPlugin } from './plugin'; + +export const plugin = (initializerContext: PluginInitializerContext) => { + return new EnterpriseSearchPlugin(initializerContext); +}; diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts new file mode 100644 index 00000000000000..fbfcc303de47a2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -0,0 +1,88 @@ +/* + * 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 { + Plugin, + PluginInitializerContext, + CoreSetup, + CoreStart, + AppMountParameters, + HttpSetup, +} from 'src/core/public'; + +import { + FeatureCatalogueCategory, + HomePublicPluginSetup, +} from '../../../../src/plugins/home/public'; +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; +import { LicensingPluginSetup } from '../../licensing/public'; + +import { getPublicUrl } from './applications/shared/enterprise_search_url'; +import AppSearchLogo from './applications/app_search/assets/logo.svg'; + +export interface ClientConfigType { + host?: string; +} +export interface PluginsSetup { + home: HomePublicPluginSetup; + licensing: LicensingPluginSetup; +} + +export class EnterpriseSearchPlugin implements Plugin { + private config: ClientConfigType; + private hasCheckedPublicUrl: boolean = false; + + constructor(initializerContext: PluginInitializerContext) { + this.config = initializerContext.config.get(); + } + + public setup(core: CoreSetup, plugins: PluginsSetup) { + const config = { host: this.config.host }; + + core.application.register({ + id: 'appSearch', + title: 'App Search', + appRoute: '/app/enterprise_search/app_search', + category: DEFAULT_APP_CATEGORIES.enterpriseSearch, + mount: async (params: AppMountParameters) => { + const [coreStart] = await core.getStartServices(); + + await this.setPublicUrl(config, coreStart.http); + + const { renderApp } = await import('./applications'); + const { AppSearch } = await import('./applications/app_search'); + + return renderApp(AppSearch, coreStart, params, config, plugins); + }, + }); + // TODO: Workplace Search will need to register its own plugin. + + plugins.home.featureCatalogue.register({ + id: 'appSearch', + title: 'App Search', + icon: AppSearchLogo, + description: + 'Leverage dashboards, analytics, and APIs for advanced application search made simple.', + path: '/app/enterprise_search/app_search', + category: FeatureCatalogueCategory.DATA, + showOnHomePage: true, + }); + // TODO: Workplace Search will need to register its own feature catalogue section/card. + } + + public start(core: CoreStart) {} + + public stop() {} + + private async setPublicUrl(config: ClientConfigType, http: HttpSetup) { + if (!config.host) return; // No API to check + if (this.hasCheckedPublicUrl) return; // We've already performed the check + + const publicUrl = await getPublicUrl(http); + if (publicUrl) config.host = publicUrl; + this.hasCheckedPublicUrl = true; + } +} diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts new file mode 100644 index 00000000000000..e95056b8713248 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts @@ -0,0 +1,143 @@ +/* + * 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 { loggingSystemMock } from 'src/core/server/mocks'; + +jest.mock('../../../../../../src/core/server', () => ({ + SavedObjectsErrorHelpers: { + isNotFoundError: jest.fn(), + }, +})); +import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; + +import { registerTelemetryUsageCollector, incrementUICounter } from './telemetry'; + +describe('App Search Telemetry Usage Collector', () => { + const mockLogger = loggingSystemMock.create().get(); + + const makeUsageCollectorStub = jest.fn(); + const registerStub = jest.fn(); + const usageCollectionMock = { + makeUsageCollector: makeUsageCollectorStub, + registerCollector: registerStub, + } as any; + + const savedObjectsRepoStub = { + get: () => ({ + attributes: { + 'ui_viewed.setup_guide': 10, + 'ui_viewed.engines_overview': 20, + 'ui_error.cannot_connect': 3, + 'ui_clicked.create_first_engine_button': 40, + 'ui_clicked.header_launch_button': 50, + 'ui_clicked.engine_table_link': 60, + }, + }), + incrementCounter: jest.fn(), + }; + const savedObjectsMock = { + createInternalRepository: jest.fn(() => savedObjectsRepoStub), + } as any; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('registerTelemetryUsageCollector', () => { + it('should make and register the usage collector', () => { + registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock, mockLogger); + + expect(registerStub).toHaveBeenCalledTimes(1); + expect(makeUsageCollectorStub).toHaveBeenCalledTimes(1); + expect(makeUsageCollectorStub.mock.calls[0][0].type).toBe('app_search'); + expect(makeUsageCollectorStub.mock.calls[0][0].isReady()).toBe(true); + }); + }); + + describe('fetchTelemetryMetrics', () => { + it('should return existing saved objects data', async () => { + registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock, mockLogger); + const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch(); + + expect(savedObjectsCounts).toEqual({ + ui_viewed: { + setup_guide: 10, + engines_overview: 20, + }, + ui_error: { + cannot_connect: 3, + }, + ui_clicked: { + create_first_engine_button: 40, + header_launch_button: 50, + engine_table_link: 60, + }, + }); + }); + + it('should return a default telemetry object if no saved data exists', async () => { + const emptySavedObjectsMock = { + createInternalRepository: () => ({ + get: () => ({ attributes: null }), + }), + } as any; + + registerTelemetryUsageCollector(usageCollectionMock, emptySavedObjectsMock, mockLogger); + const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch(); + + expect(savedObjectsCounts).toEqual({ + ui_viewed: { + setup_guide: 0, + engines_overview: 0, + }, + ui_error: { + cannot_connect: 0, + }, + ui_clicked: { + create_first_engine_button: 0, + header_launch_button: 0, + engine_table_link: 0, + }, + }); + }); + + it('should not throw but log a warning if saved objects errors', async () => { + const errorSavedObjectsMock = { createInternalRepository: () => ({}) } as any; + registerTelemetryUsageCollector(usageCollectionMock, errorSavedObjectsMock, mockLogger); + + // Without log warning (not found) + (SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => true); + await makeUsageCollectorStub.mock.calls[0][0].fetch(); + + expect(mockLogger.warn).not.toHaveBeenCalled(); + + // With log warning + (SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => false); + await makeUsageCollectorStub.mock.calls[0][0].fetch(); + + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Failed to retrieve App Search telemetry data: TypeError: savedObjectsRepository.get is not a function' + ); + }); + }); + + describe('incrementUICounter', () => { + it('should increment the saved objects internal repository', async () => { + const response = await incrementUICounter({ + savedObjects: savedObjectsMock, + uiAction: 'ui_clicked', + metric: 'button', + }); + + expect(savedObjectsRepoStub.incrementCounter).toHaveBeenCalledWith( + 'app_search_telemetry', + 'app_search_telemetry', + 'ui_clicked.button' + ); + expect(response).toEqual({ success: true }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts new file mode 100644 index 00000000000000..a10f96907ad28a --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts @@ -0,0 +1,156 @@ +/* + * 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 { get } from 'lodash'; +import { + ISavedObjectsRepository, + SavedObjectsServiceStart, + SavedObjectAttributes, + Logger, +} from 'src/core/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; + +// This throws `Error: Cannot find module 'src/core/server'` if I import it via alias ¯\_(ツ)_/¯ +import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; + +interface ITelemetry { + ui_viewed: { + setup_guide: number; + engines_overview: number; + }; + ui_error: { + cannot_connect: number; + }; + ui_clicked: { + create_first_engine_button: number; + header_launch_button: number; + engine_table_link: number; + }; +} + +export const AS_TELEMETRY_NAME = 'app_search_telemetry'; + +/** + * Register the telemetry collector + */ + +export const registerTelemetryUsageCollector = ( + usageCollection: UsageCollectionSetup, + savedObjects: SavedObjectsServiceStart, + log: Logger +) => { + const telemetryUsageCollector = usageCollection.makeUsageCollector({ + type: 'app_search', + fetch: async () => fetchTelemetryMetrics(savedObjects, log), + isReady: () => true, + schema: { + ui_viewed: { + setup_guide: { type: 'long' }, + engines_overview: { type: 'long' }, + }, + ui_error: { + cannot_connect: { type: 'long' }, + }, + ui_clicked: { + create_first_engine_button: { type: 'long' }, + header_launch_button: { type: 'long' }, + engine_table_link: { type: 'long' }, + }, + }, + }); + usageCollection.registerCollector(telemetryUsageCollector); +}; + +/** + * Fetch the aggregated telemetry metrics from our saved objects + */ + +const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart, log: Logger) => { + const savedObjectsRepository = savedObjects.createInternalRepository(); + const savedObjectAttributes = (await getSavedObjectAttributesFromRepo( + savedObjectsRepository, + log + )) as SavedObjectAttributes; + + const defaultTelemetrySavedObject: ITelemetry = { + ui_viewed: { + setup_guide: 0, + engines_overview: 0, + }, + ui_error: { + cannot_connect: 0, + }, + ui_clicked: { + create_first_engine_button: 0, + header_launch_button: 0, + engine_table_link: 0, + }, + }; + + // If we don't have an existing/saved telemetry object, return the default + if (!savedObjectAttributes) { + return defaultTelemetrySavedObject; + } + + return { + ui_viewed: { + setup_guide: get(savedObjectAttributes, 'ui_viewed.setup_guide', 0), + engines_overview: get(savedObjectAttributes, 'ui_viewed.engines_overview', 0), + }, + ui_error: { + cannot_connect: get(savedObjectAttributes, 'ui_error.cannot_connect', 0), + }, + ui_clicked: { + create_first_engine_button: get( + savedObjectAttributes, + 'ui_clicked.create_first_engine_button', + 0 + ), + header_launch_button: get(savedObjectAttributes, 'ui_clicked.header_launch_button', 0), + engine_table_link: get(savedObjectAttributes, 'ui_clicked.engine_table_link', 0), + }, + } as ITelemetry; +}; + +/** + * Helper function - fetches saved objects attributes + */ + +const getSavedObjectAttributesFromRepo = async ( + savedObjectsRepository: ISavedObjectsRepository, + log: Logger +) => { + try { + return (await savedObjectsRepository.get(AS_TELEMETRY_NAME, AS_TELEMETRY_NAME)).attributes; + } catch (e) { + if (!SavedObjectsErrorHelpers.isNotFoundError(e)) { + log.warn(`Failed to retrieve App Search telemetry data: ${e}`); + } + return null; + } +}; + +/** + * Set saved objection attributes - used by telemetry route + */ + +interface IIncrementUICounter { + savedObjects: SavedObjectsServiceStart; + uiAction: string; + metric: string; +} + +export async function incrementUICounter({ savedObjects, uiAction, metric }: IIncrementUICounter) { + const internalRepository = savedObjects.createInternalRepository(); + + await internalRepository.incrementCounter( + AS_TELEMETRY_NAME, + AS_TELEMETRY_NAME, + `${uiAction}.${metric}` // e.g., ui_viewed.setup_guide + ); + + return { success: true }; +} diff --git a/x-pack/plugins/enterprise_search/server/index.ts b/x-pack/plugins/enterprise_search/server/index.ts new file mode 100644 index 00000000000000..1e4159124ed942 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/index.ts @@ -0,0 +1,29 @@ +/* + * 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 { PluginInitializerContext, PluginConfigDescriptor } from 'src/core/server'; +import { schema, TypeOf } from '@kbn/config-schema'; +import { EnterpriseSearchPlugin } from './plugin'; + +export const plugin = (initializerContext: PluginInitializerContext) => { + return new EnterpriseSearchPlugin(initializerContext); +}; + +export const configSchema = schema.object({ + host: schema.maybe(schema.string()), + enabled: schema.boolean({ defaultValue: true }), + accessCheckTimeout: schema.number({ defaultValue: 5000 }), + accessCheckTimeoutWarning: schema.number({ defaultValue: 300 }), +}); + +export type ConfigType = TypeOf; + +export const config: PluginConfigDescriptor = { + schema: configSchema, + exposeToBrowser: { + host: true, + }, +}; diff --git a/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts b/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts new file mode 100644 index 00000000000000..11d4a387b533ff --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts @@ -0,0 +1,128 @@ +/* + * 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. + */ + +jest.mock('./enterprise_search_config_api', () => ({ + callEnterpriseSearchConfigAPI: jest.fn(), +})); +import { callEnterpriseSearchConfigAPI } from './enterprise_search_config_api'; + +import { checkAccess } from './check_access'; + +describe('checkAccess', () => { + const mockSecurity = { + authz: { + mode: { + useRbacForRequest: () => true, + }, + checkPrivilegesWithRequest: () => ({ + globally: () => ({ + hasAllRequested: false, + }), + }), + actions: { + ui: { + get: () => null, + }, + }, + }, + }; + const mockDependencies = { + request: {}, + config: { host: 'http://localhost:3002' }, + security: mockSecurity, + } as any; + + describe('when security is disabled', () => { + it('should allow all access', async () => { + const security = undefined; + expect(await checkAccess({ ...mockDependencies, security })).toEqual({ + hasAppSearchAccess: true, + hasWorkplaceSearchAccess: true, + }); + }); + }); + + describe('when the user is a superuser', () => { + it('should allow all access', async () => { + const security = { + ...mockSecurity, + authz: { + mode: { useRbacForRequest: () => true }, + checkPrivilegesWithRequest: () => ({ + globally: () => ({ + hasAllRequested: true, + }), + }), + actions: { ui: { get: () => {} } }, + }, + }; + expect(await checkAccess({ ...mockDependencies, security })).toEqual({ + hasAppSearchAccess: true, + hasWorkplaceSearchAccess: true, + }); + }); + + it('falls back to assuming a non-superuser role if auth credentials are missing', async () => { + const security = { + authz: { + ...mockSecurity.authz, + checkPrivilegesWithRequest: () => ({ + globally: () => Promise.reject({ statusCode: 403 }), + }), + }, + }; + expect(await checkAccess({ ...mockDependencies, security })).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: false, + }); + }); + + it('throws other authz errors', async () => { + const security = { + authz: { + ...mockSecurity.authz, + checkPrivilegesWithRequest: undefined, + }, + }; + await expect(checkAccess({ ...mockDependencies, security })).rejects.toThrow(); + }); + }); + + describe('when the user is a non-superuser', () => { + describe('when enterpriseSearch.host is not set in kibana.yml', () => { + it('should deny all access', async () => { + const config = { host: undefined }; + expect(await checkAccess({ ...mockDependencies, config })).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: false, + }); + }); + }); + + describe('when enterpriseSearch.host is set in kibana.yml', () => { + it('should make a http call and return the access response', async () => { + (callEnterpriseSearchConfigAPI as jest.Mock).mockImplementationOnce(() => ({ + access: { + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: true, + }, + })); + expect(await checkAccess(mockDependencies)).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: true, + }); + }); + + it('falls back to no access if no http response', async () => { + (callEnterpriseSearchConfigAPI as jest.Mock).mockImplementationOnce(() => ({})); + expect(await checkAccess(mockDependencies)).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: false, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/lib/check_access.ts b/x-pack/plugins/enterprise_search/server/lib/check_access.ts new file mode 100644 index 00000000000000..0239cb6422d032 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/check_access.ts @@ -0,0 +1,76 @@ +/* + * 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 { KibanaRequest, Logger } from 'src/core/server'; +import { SecurityPluginSetup } from '../../../security/server'; +import { ConfigType } from '../'; + +import { callEnterpriseSearchConfigAPI } from './enterprise_search_config_api'; + +interface ICheckAccess { + request: KibanaRequest; + security?: SecurityPluginSetup; + config: ConfigType; + log: Logger; +} +export interface IAccess { + hasAppSearchAccess: boolean; + hasWorkplaceSearchAccess: boolean; +} + +const ALLOW_ALL_PLUGINS = { + hasAppSearchAccess: true, + hasWorkplaceSearchAccess: true, +}; +const DENY_ALL_PLUGINS = { + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: false, +}; + +/** + * Determines whether the user has access to our Enterprise Search products + * via HTTP call. If not, we hide the corresponding plugin links from the + * nav and catalogue in `plugin.ts`, which disables plugin access + */ +export const checkAccess = async ({ + config, + security, + request, + log, +}: ICheckAccess): Promise => { + // If security has been disabled, always show the plugin + if (!security?.authz.mode.useRbacForRequest(request)) { + return ALLOW_ALL_PLUGINS; + } + + // If the user is a "superuser" or has the base Kibana all privilege globally, always show the plugin + const isSuperUser = async (): Promise => { + try { + const { hasAllRequested } = await security.authz + .checkPrivilegesWithRequest(request) + .globally(security.authz.actions.ui.get('enterpriseSearch', 'all')); + return hasAllRequested; + } catch (err) { + if (err.statusCode === 401 || err.statusCode === 403) { + return false; + } + throw err; + } + }; + if (await isSuperUser()) { + return ALLOW_ALL_PLUGINS; + } + + // Hide the plugin when enterpriseSearch.host is not defined in kibana.yml + if (!config.host) { + return DENY_ALL_PLUGINS; + } + + // When enterpriseSearch.host is defined in kibana.yml, + // make a HTTP call which returns product access + const { access } = (await callEnterpriseSearchConfigAPI({ request, config, log })) || {}; + return access || DENY_ALL_PLUGINS; +}; diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts new file mode 100644 index 00000000000000..cf35a458b48258 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts @@ -0,0 +1,111 @@ +/* + * 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. + */ + +jest.mock('node-fetch'); +const fetchMock = require('node-fetch') as jest.Mock; +const { Response } = jest.requireActual('node-fetch'); + +import { loggingSystemMock } from 'src/core/server/mocks'; + +import { callEnterpriseSearchConfigAPI } from './enterprise_search_config_api'; + +describe('callEnterpriseSearchConfigAPI', () => { + const mockConfig = { + host: 'http://localhost:3002', + accessCheckTimeout: 200, + accessCheckTimeoutWarning: 100, + }; + const mockRequest = { + url: { path: '/app/kibana' }, + headers: { authorization: '==someAuth' }, + }; + const mockDependencies = { + config: mockConfig, + request: mockRequest, + log: loggingSystemMock.create().get(), + } as any; + + const mockResponse = { + version: { + number: '1.0.0', + }, + settings: { + external_url: 'http://some.vanity.url/', + }, + access: { + user: 'someuser', + products: { + app_search: true, + workplace_search: false, + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls the config API endpoint', async () => { + fetchMock.mockImplementationOnce((url: string) => { + expect(url).toEqual('http://localhost:3002/api/ent/v1/internal/client_config'); + return Promise.resolve(new Response(JSON.stringify(mockResponse))); + }); + + expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({ + publicUrl: 'http://some.vanity.url/', + access: { + hasAppSearchAccess: true, + hasWorkplaceSearchAccess: false, + }, + }); + }); + + it('returns early if config.host is not set', async () => { + const config = { host: '' }; + + expect(await callEnterpriseSearchConfigAPI({ ...mockDependencies, config })).toEqual({}); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('handles server errors', async () => { + fetchMock.mockImplementationOnce(() => { + return Promise.reject('500'); + }); + expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({}); + expect(mockDependencies.log.error).toHaveBeenCalledWith( + 'Could not perform access check to Enterprise Search: 500' + ); + + fetchMock.mockImplementationOnce(() => { + return Promise.resolve('Bad Data'); + }); + expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({}); + expect(mockDependencies.log.error).toHaveBeenCalledWith( + 'Could not perform access check to Enterprise Search: TypeError: response.json is not a function' + ); + }); + + it('handles timeouts', async () => { + jest.useFakeTimers(); + + // Warning + callEnterpriseSearchConfigAPI(mockDependencies); + jest.advanceTimersByTime(150); + expect(mockDependencies.log.warn).toHaveBeenCalledWith( + 'Enterprise Search access check took over 100ms. Please ensure your Enterprise Search server is respondingly normally and not adversely impacting Kibana load speeds.' + ); + + // Timeout + fetchMock.mockImplementationOnce(async () => { + jest.advanceTimersByTime(250); + return Promise.reject({ name: 'AbortError' }); + }); + expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({}); + expect(mockDependencies.log.warn).toHaveBeenCalledWith( + "Exceeded 200ms timeout while checking http://localhost:3002. Please consider increasing your enterpriseSearch.accessCheckTimeout value so that users aren't prevented from accessing Enterprise Search plugins due to slow responses." + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts new file mode 100644 index 00000000000000..7a6d1eac1b4545 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import AbortController from 'abort-controller'; +import fetch from 'node-fetch'; + +import { KibanaRequest, Logger } from 'src/core/server'; +import { ConfigType } from '../'; +import { IAccess } from './check_access'; + +interface IParams { + request: KibanaRequest; + config: ConfigType; + log: Logger; +} +interface IReturn { + publicUrl?: string; + access?: IAccess; +} + +/** + * Calls an internal Enterprise Search API endpoint which returns + * useful various settings (e.g. product access, external URL) + * needed by the Kibana plugin at the setup stage + */ +const ENDPOINT = '/api/ent/v1/internal/client_config'; + +export const callEnterpriseSearchConfigAPI = async ({ + config, + log, + request, +}: IParams): Promise => { + if (!config.host) return {}; + + const TIMEOUT_WARNING = `Enterprise Search access check took over ${config.accessCheckTimeoutWarning}ms. Please ensure your Enterprise Search server is respondingly normally and not adversely impacting Kibana load speeds.`; + const TIMEOUT_MESSAGE = `Exceeded ${config.accessCheckTimeout}ms timeout while checking ${config.host}. Please consider increasing your enterpriseSearch.accessCheckTimeout value so that users aren't prevented from accessing Enterprise Search plugins due to slow responses.`; + const CONNECTION_ERROR = 'Could not perform access check to Enterprise Search'; + + const warningTimeout = setTimeout(() => { + log.warn(TIMEOUT_WARNING); + }, config.accessCheckTimeoutWarning); + + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, config.accessCheckTimeout); + + try { + const enterpriseSearchUrl = encodeURI(`${config.host}${ENDPOINT}`); + const response = await fetch(enterpriseSearchUrl, { + headers: { Authorization: request.headers.authorization as string }, + signal: controller.signal, + }); + const data = await response.json(); + + return { + publicUrl: data?.settings?.external_url, + access: { + hasAppSearchAccess: !!data?.access?.products?.app_search, + hasWorkplaceSearchAccess: !!data?.access?.products?.workplace_search, + }, + }; + } catch (err) { + if (err.name === 'AbortError') { + log.warn(TIMEOUT_MESSAGE); + } else { + log.error(`${CONNECTION_ERROR}: ${err.toString()}`); + if (err instanceof Error) log.debug(err.stack as string); + } + return {}; + } finally { + clearTimeout(warningTimeout); + clearTimeout(timeout); + } +}; diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts new file mode 100644 index 00000000000000..70be8600862e9c --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -0,0 +1,121 @@ +/* + * 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 { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { + Plugin, + PluginInitializerContext, + CoreSetup, + Logger, + SavedObjectsServiceStart, + IRouter, + KibanaRequest, +} from 'src/core/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { SecurityPluginSetup } from '../../security/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; + +import { ConfigType } from './'; +import { checkAccess } from './lib/check_access'; +import { registerPublicUrlRoute } from './routes/enterprise_search/public_url'; +import { registerEnginesRoute } from './routes/app_search/engines'; +import { registerTelemetryRoute } from './routes/app_search/telemetry'; +import { registerTelemetryUsageCollector } from './collectors/app_search/telemetry'; +import { appSearchTelemetryType } from './saved_objects/app_search/telemetry'; + +export interface PluginsSetup { + usageCollection?: UsageCollectionSetup; + security?: SecurityPluginSetup; + features: FeaturesPluginSetup; +} + +export interface IRouteDependencies { + router: IRouter; + config: ConfigType; + log: Logger; + getSavedObjectsService?(): SavedObjectsServiceStart; +} + +export class EnterpriseSearchPlugin implements Plugin { + private config: Observable; + private logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.config = initializerContext.config.create(); + this.logger = initializerContext.logger.get(); + } + + public async setup( + { capabilities, http, savedObjects, getStartServices }: CoreSetup, + { usageCollection, security, features }: PluginsSetup + ) { + const config = await this.config.pipe(first()).toPromise(); + + /** + * Register space/feature control + */ + features.registerFeature({ + id: 'enterpriseSearch', + name: 'Enterprise Search', + order: 0, + icon: 'logoEnterpriseSearch', + navLinkId: 'appSearch', // TODO - remove this once functional tests no longer rely on navLinkId + app: ['kibana', 'appSearch'], // TODO: 'enterpriseSearch', 'workplaceSearch' + catalogue: ['appSearch'], // TODO: 'enterpriseSearch', 'workplaceSearch' + privileges: null, + }); + + /** + * Register user access to the Enterprise Search plugins + */ + capabilities.registerSwitcher(async (request: KibanaRequest) => { + const dependencies = { config, security, request, log: this.logger }; + + const { hasAppSearchAccess } = await checkAccess(dependencies); + // TODO: hasWorkplaceSearchAccess + + return { + navLinks: { + appSearch: hasAppSearchAccess, + }, + catalogue: { + appSearch: hasAppSearchAccess, + }, + }; + }); + + /** + * Register routes + */ + const router = http.createRouter(); + const dependencies = { router, config, log: this.logger }; + + registerPublicUrlRoute(dependencies); + registerEnginesRoute(dependencies); + + /** + * Bootstrap the routes, saved objects, and collector for telemetry + */ + savedObjects.registerType(appSearchTelemetryType); + let savedObjectsStarted: SavedObjectsServiceStart; + + getStartServices().then(([coreStart]) => { + savedObjectsStarted = coreStart.savedObjects; + if (usageCollection) { + registerTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger); + } + }); + registerTelemetryRoute({ + ...dependencies, + getSavedObjectsService: () => savedObjectsStarted, + }); + } + + public start() {} + + public stop() {} +} diff --git a/x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts b/x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts new file mode 100644 index 00000000000000..3cca5e21ce9c3c --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { MockRouter } from './router.mock'; +export { mockConfig, mockLogger, mockDependencies } from './routerDependencies.mock'; diff --git a/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts b/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts new file mode 100644 index 00000000000000..1ca7755979f99b --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts @@ -0,0 +1,102 @@ +/* + * 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 { httpServiceMock, httpServerMock } from 'src/core/server/mocks'; +import { + IRouter, + KibanaRequest, + RequestHandlerContext, + RouteValidatorConfig, +} from 'src/core/server'; + +/** + * Test helper that mocks Kibana's router and DRYs out various helper (callRoute, schema validation) + */ + +type methodType = 'get' | 'post' | 'put' | 'patch' | 'delete'; +type payloadType = 'params' | 'query' | 'body'; + +interface IMockRouterProps { + method: methodType; + payload?: payloadType; +} +interface IMockRouterRequest { + body?: object; + query?: object; + params?: object; +} +type TMockRouterRequest = KibanaRequest | IMockRouterRequest; + +export class MockRouter { + public router!: jest.Mocked; + public method: methodType; + public payload?: payloadType; + public response = httpServerMock.createResponseFactory(); + + constructor({ method, payload }: IMockRouterProps) { + this.createRouter(); + this.method = method; + this.payload = payload; + } + + public createRouter = () => { + this.router = httpServiceMock.createRouter(); + }; + + public callRoute = async (request: TMockRouterRequest) => { + const [, handler] = this.router[this.method].mock.calls[0]; + + const context = {} as jest.Mocked; + await handler(context, httpServerMock.createKibanaRequest(request as any), this.response); + }; + + /** + * Schema validation helpers + */ + + public validateRoute = (request: TMockRouterRequest) => { + if (!this.payload) throw new Error('Cannot validate wihout a payload type specified.'); + + const [config] = this.router[this.method].mock.calls[0]; + const validate = config.validate as RouteValidatorConfig<{}, {}, {}>; + + const payloadValidation = validate[this.payload] as { validate(request: KibanaRequest): void }; + const payloadRequest = request[this.payload] as KibanaRequest; + + payloadValidation.validate(payloadRequest); + }; + + public shouldValidate = (request: TMockRouterRequest) => { + expect(() => this.validateRoute(request)).not.toThrow(); + }; + + public shouldThrow = (request: TMockRouterRequest) => { + expect(() => this.validateRoute(request)).toThrow(); + }; +} + +/** + * Example usage: + */ +// const mockRouter = new MockRouter({ method: 'get', payload: 'body' }); +// +// beforeEach(() => { +// jest.clearAllMocks(); +// mockRouter.createRouter(); +// +// registerExampleRoute({ router: mockRouter.router, ...dependencies }); // Whatever other dependencies the route needs +// }); + +// it('hits the endpoint successfully', async () => { +// await mockRouter.callRoute({ body: { foo: 'bar' } }); +// +// expect(mockRouter.response.ok).toHaveBeenCalled(); +// }); + +// it('validates', () => { +// const request = { body: { foo: 'bar' } }; +// mockRouter.shouldValidate(request); +// }); diff --git a/x-pack/plugins/enterprise_search/server/routes/__mocks__/routerDependencies.mock.ts b/x-pack/plugins/enterprise_search/server/routes/__mocks__/routerDependencies.mock.ts new file mode 100644 index 00000000000000..9b6fa30271d613 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/__mocks__/routerDependencies.mock.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 { loggingSystemMock } from 'src/core/server/mocks'; +import { ConfigType } from '../../'; + +export const mockLogger = loggingSystemMock.createLogger().get(); + +export const mockConfig = { + enabled: true, + host: 'http://localhost:3002', + accessCheckTimeout: 5000, + accessCheckTimeoutWarning: 300, +} as ConfigType; + +/** + * This is useful for tests that don't use either config or log, + * but should still pass them in to pass Typescript definitions + */ +export const mockDependencies = { + // Mock router should be handled on a per-test basis + config: mockConfig, + log: mockLogger, +}; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts new file mode 100644 index 00000000000000..d5b1bc50034562 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts @@ -0,0 +1,160 @@ +/* + * 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 { MockRouter, mockConfig, mockLogger } from '../__mocks__'; + +import { registerEnginesRoute } from './engines'; + +jest.mock('node-fetch'); +const fetch = jest.requireActual('node-fetch'); +const { Response } = fetch; +const fetchMock = require('node-fetch') as jest.Mocked; + +describe('engine routes', () => { + describe('GET /api/app_search/engines', () => { + const AUTH_HEADER = 'Basic 123'; + const mockRequest = { + headers: { + authorization: AUTH_HEADER, + }, + query: { + type: 'indexed', + pageIndex: 1, + }, + }; + + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ method: 'get', payload: 'query' }); + + registerEnginesRoute({ + router: mockRouter.router, + log: mockLogger, + config: mockConfig, + }); + }); + + describe('when the underlying App Search API returns a 200', () => { + beforeEach(() => { + AppSearchAPI.shouldBeCalledWith( + `http://localhost:3002/as/engines/collection?type=indexed&page%5Bcurrent%5D=1&page%5Bsize%5D=10`, + { headers: { Authorization: AUTH_HEADER } } + ).andReturn({ + results: [{ name: 'engine1' }], + meta: { page: { total_results: 1 } }, + }); + }); + + it('should return 200 with a list of engines from the App Search API', async () => { + await mockRouter.callRoute(mockRequest); + + expect(mockRouter.response.ok).toHaveBeenCalledWith({ + body: { results: [{ name: 'engine1' }], meta: { page: { total_results: 1 } } }, + }); + }); + }); + + describe('when the App Search URL is invalid', () => { + beforeEach(() => { + AppSearchAPI.shouldBeCalledWith( + `http://localhost:3002/as/engines/collection?type=indexed&page%5Bcurrent%5D=1&page%5Bsize%5D=10`, + { headers: { Authorization: AUTH_HEADER } } + ).andReturnError(); + }); + + it('should return 404 with a message', async () => { + await mockRouter.callRoute(mockRequest); + + expect(mockRouter.response.notFound).toHaveBeenCalledWith({ + body: 'cannot-connect', + }); + expect(mockLogger.error).toHaveBeenCalledWith('Cannot connect to App Search: Failed'); + expect(mockLogger.debug).not.toHaveBeenCalled(); + }); + }); + + describe('when the App Search API returns invalid data', () => { + beforeEach(() => { + AppSearchAPI.shouldBeCalledWith( + `http://localhost:3002/as/engines/collection?type=indexed&page%5Bcurrent%5D=1&page%5Bsize%5D=10`, + { headers: { Authorization: AUTH_HEADER } } + ).andReturnInvalidData(); + }); + + it('should return 404 with a message', async () => { + await mockRouter.callRoute(mockRequest); + + expect(mockRouter.response.notFound).toHaveBeenCalledWith({ + body: 'cannot-connect', + }); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Cannot connect to App Search: Error: Invalid data received from App Search: {"foo":"bar"}' + ); + expect(mockLogger.debug).toHaveBeenCalled(); + }); + }); + + describe('validates', () => { + it('correctly', () => { + const request = { query: { type: 'meta', pageIndex: 5 } }; + mockRouter.shouldValidate(request); + }); + + it('wrong pageIndex type', () => { + const request = { query: { type: 'indexed', pageIndex: 'indexed' } }; + mockRouter.shouldThrow(request); + }); + + it('wrong type string', () => { + const request = { query: { type: 'invalid', pageIndex: 1 } }; + mockRouter.shouldThrow(request); + }); + + it('missing pageIndex', () => { + const request = { query: { type: 'indexed' } }; + mockRouter.shouldThrow(request); + }); + + it('missing type', () => { + const request = { query: { pageIndex: 1 } }; + mockRouter.shouldThrow(request); + }); + }); + + const AppSearchAPI = { + shouldBeCalledWith(expectedUrl: string, expectedParams: object) { + return { + andReturn(response: object) { + fetchMock.mockImplementation((url: string, params: object) => { + expect(url).toEqual(expectedUrl); + expect(params).toEqual(expectedParams); + + return Promise.resolve(new Response(JSON.stringify(response))); + }); + }, + andReturnInvalidData() { + fetchMock.mockImplementation((url: string, params: object) => { + expect(url).toEqual(expectedUrl); + expect(params).toEqual(expectedParams); + + return Promise.resolve(new Response(JSON.stringify({ foo: 'bar' }))); + }); + }, + andReturnError() { + fetchMock.mockImplementation((url: string, params: object) => { + expect(url).toEqual(expectedUrl); + expect(params).toEqual(expectedParams); + + return Promise.reject('Failed'); + }); + }, + }; + }, + }; + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts new file mode 100644 index 00000000000000..ca83c0e187ddb5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts @@ -0,0 +1,59 @@ +/* + * 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 fetch from 'node-fetch'; +import querystring from 'querystring'; +import { schema } from '@kbn/config-schema'; + +import { IRouteDependencies } from '../../plugin'; +import { ENGINES_PAGE_SIZE } from '../../../common/constants'; + +export function registerEnginesRoute({ router, config, log }: IRouteDependencies) { + router.get( + { + path: '/api/app_search/engines', + validate: { + query: schema.object({ + type: schema.oneOf([schema.literal('indexed'), schema.literal('meta')]), + pageIndex: schema.number(), + }), + }, + }, + async (context, request, response) => { + try { + const enterpriseSearchUrl = config.host as string; + const { type, pageIndex } = request.query; + + const params = querystring.stringify({ + type, + 'page[current]': pageIndex, + 'page[size]': ENGINES_PAGE_SIZE, + }); + const url = `${encodeURI(enterpriseSearchUrl)}/as/engines/collection?${params}`; + + const enginesResponse = await fetch(url, { + headers: { Authorization: request.headers.authorization as string }, + }); + + const engines = await enginesResponse.json(); + const hasValidData = + Array.isArray(engines?.results) && typeof engines?.meta?.page?.total_results === 'number'; + + if (hasValidData) { + return response.ok({ body: engines }); + } else { + // Either a completely incorrect Enterprise Search host URL was configured, or App Search is returning bad data + throw new Error(`Invalid data received from App Search: ${JSON.stringify(engines)}`); + } + } catch (e) { + log.error(`Cannot connect to App Search: ${e.toString()}`); + if (e instanceof Error) log.debug(e.stack as string); + + return response.notFound({ body: 'cannot-connect' }); + } + } + ); +} diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts new file mode 100644 index 00000000000000..e2d5fbcec37056 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts @@ -0,0 +1,108 @@ +/* + * 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 { loggingSystemMock, savedObjectsServiceMock } from 'src/core/server/mocks'; +import { MockRouter, mockConfig, mockLogger } from '../__mocks__'; + +import { registerTelemetryRoute } from './telemetry'; + +jest.mock('../../collectors/app_search/telemetry', () => ({ + incrementUICounter: jest.fn(), +})); +import { incrementUICounter } from '../../collectors/app_search/telemetry'; + +/** + * Since these route callbacks are so thin, these serve simply as integration tests + * to ensure they're wired up to the collector functions correctly. Business logic + * is tested more thoroughly in the collectors/telemetry tests. + */ +describe('App Search Telemetry API', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ method: 'put', payload: 'body' }); + + registerTelemetryRoute({ + router: mockRouter.router, + getSavedObjectsService: () => savedObjectsServiceMock.createStartContract(), + log: mockLogger, + config: mockConfig, + }); + }); + + describe('PUT /api/app_search/telemetry', () => { + it('increments the saved objects counter', async () => { + const successResponse = { success: true }; + (incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => successResponse)); + + await mockRouter.callRoute({ body: { action: 'viewed', metric: 'setup_guide' } }); + + expect(incrementUICounter).toHaveBeenCalledWith({ + savedObjects: expect.any(Object), + uiAction: 'ui_viewed', + metric: 'setup_guide', + }); + expect(mockRouter.response.ok).toHaveBeenCalledWith({ body: successResponse }); + }); + + it('throws an error when incrementing fails', async () => { + (incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => Promise.reject('Failed'))); + + await mockRouter.callRoute({ body: { action: 'error', metric: 'error' } }); + + expect(incrementUICounter).toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalled(); + expect(mockRouter.response.internalError).toHaveBeenCalled(); + }); + + it('throws an error if the Saved Objects service is unavailable', async () => { + jest.clearAllMocks(); + registerTelemetryRoute({ + router: mockRouter.router, + getSavedObjectsService: null, + log: mockLogger, + } as any); + await mockRouter.callRoute({}); + + expect(incrementUICounter).not.toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalled(); + expect(mockRouter.response.internalError).toHaveBeenCalled(); + expect(loggingSystemMock.collect(mockLogger).error[0][0]).toEqual( + expect.stringContaining( + 'App Search UI telemetry error: Error: Could not find Saved Objects service' + ) + ); + }); + + describe('validates', () => { + it('correctly', () => { + const request = { body: { action: 'viewed', metric: 'setup_guide' } }; + mockRouter.shouldValidate(request); + }); + + it('wrong action string', () => { + const request = { body: { action: 'invalid', metric: 'setup_guide' } }; + mockRouter.shouldThrow(request); + }); + + it('wrong metric type', () => { + const request = { body: { action: 'clicked', metric: true } }; + mockRouter.shouldThrow(request); + }); + + it('action is missing', () => { + const request = { body: { metric: 'engines_overview' } }; + mockRouter.shouldThrow(request); + }); + + it('metric is missing', () => { + const request = { body: { action: 'error' } }; + mockRouter.shouldThrow(request); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts new file mode 100644 index 00000000000000..4cc9b64adc0927 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts @@ -0,0 +1,50 @@ +/* + * 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 { IRouteDependencies } from '../../plugin'; +import { incrementUICounter } from '../../collectors/app_search/telemetry'; + +export function registerTelemetryRoute({ + router, + getSavedObjectsService, + log, +}: IRouteDependencies) { + router.put( + { + path: '/api/app_search/telemetry', + validate: { + body: schema.object({ + action: schema.oneOf([ + schema.literal('viewed'), + schema.literal('clicked'), + schema.literal('error'), + ]), + metric: schema.string(), + }), + }, + }, + async (ctx, request, response) => { + const { action, metric } = request.body; + + try { + if (!getSavedObjectsService) throw new Error('Could not find Saved Objects service'); + + return response.ok({ + body: await incrementUICounter({ + savedObjects: getSavedObjectsService(), + uiAction: `ui_${action}`, + metric, + }), + }); + } catch (e) { + log.error(`App Search UI telemetry error: ${e instanceof Error ? e.stack : e.toString()}`); + return response.internalError({ body: 'App Search UI telemetry failed' }); + } + } + ); +} diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.test.ts new file mode 100644 index 00000000000000..846aae3fce56f0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.test.ts @@ -0,0 +1,52 @@ +/* + * 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 { MockRouter, mockDependencies } from '../__mocks__'; + +jest.mock('../../lib/enterprise_search_config_api', () => ({ + callEnterpriseSearchConfigAPI: jest.fn(), +})); +import { callEnterpriseSearchConfigAPI } from '../../lib/enterprise_search_config_api'; + +import { registerPublicUrlRoute } from './public_url'; + +describe('Enterprise Search Public URL API', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + mockRouter = new MockRouter({ method: 'get' }); + + registerPublicUrlRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + describe('GET /api/enterprise_search/public_url', () => { + it('returns a publicUrl', async () => { + (callEnterpriseSearchConfigAPI as jest.Mock).mockImplementationOnce(() => { + return Promise.resolve({ publicUrl: 'http://some.vanity.url' }); + }); + + await mockRouter.callRoute({}); + + expect(mockRouter.response.ok).toHaveBeenCalledWith({ + body: { publicUrl: 'http://some.vanity.url' }, + headers: { 'content-type': 'application/json' }, + }); + }); + + // For the most part, all error logging is handled by callEnterpriseSearchConfigAPI. + // This endpoint should mostly just fall back gracefully to an empty string + it('falls back to an empty string', async () => { + await mockRouter.callRoute({}); + expect(mockRouter.response.ok).toHaveBeenCalledWith({ + body: { publicUrl: '' }, + headers: { 'content-type': 'application/json' }, + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.ts new file mode 100644 index 00000000000000..a9edd4eb10da03 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/public_url.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouteDependencies } from '../../plugin'; +import { callEnterpriseSearchConfigAPI } from '../../lib/enterprise_search_config_api'; + +export function registerPublicUrlRoute({ router, config, log }: IRouteDependencies) { + router.get( + { + path: '/api/enterprise_search/public_url', + validate: false, + }, + async (context, request, response) => { + const { publicUrl = '' } = + (await callEnterpriseSearchConfigAPI({ request, config, log })) || {}; + + return response.ok({ + body: { publicUrl }, + headers: { 'content-type': 'application/json' }, + }); + } + ); +} diff --git a/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts new file mode 100644 index 00000000000000..32322d494b5e21 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/saved_objects/app_search/telemetry.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* istanbul ignore file */ + +import { SavedObjectsType } from 'src/core/server'; +import { AS_TELEMETRY_NAME } from '../../collectors/app_search/telemetry'; + +export const appSearchTelemetryType: SavedObjectsType = { + name: AS_TELEMETRY_NAME, + hidden: false, + namespaceType: 'agnostic', + mappings: { + dynamic: false, + properties: {}, + }, +}; diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts index 06f064a379fe6e..8a499a3eba8fa7 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts @@ -189,13 +189,15 @@ describe('features', () => { group: 'global', expectManageSpaces: true, expectGetFeatures: true, + expectEnterpriseSearch: true, }, { group: 'space', expectManageSpaces: false, expectGetFeatures: false, + expectEnterpriseSearch: false, }, -].forEach(({ group, expectManageSpaces, expectGetFeatures }) => { +].forEach(({ group, expectManageSpaces, expectGetFeatures, expectEnterpriseSearch }) => { describe(`${group}`, () => { test('actions defined in any feature privilege are included in `all`', () => { const features: Feature[] = [ @@ -256,6 +258,7 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), + ...(expectEnterpriseSearch ? [actions.ui.get('enterpriseSearch', 'all')] : []), actions.ui.get('catalogue', 'all-catalogue-1'), actions.ui.get('catalogue', 'all-catalogue-2'), actions.ui.get('management', 'all-management', 'all-management-1'), @@ -450,6 +453,7 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), + ...(expectEnterpriseSearch ? [actions.ui.get('enterpriseSearch', 'all')] : []), ]); expect(actual).toHaveProperty(`${group}.read`, [actions.login, actions.version]); }); @@ -514,6 +518,7 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), + ...(expectEnterpriseSearch ? [actions.ui.get('enterpriseSearch', 'all')] : []), ]); expect(actual).toHaveProperty(`${group}.read`, [actions.login, actions.version]); }); @@ -579,6 +584,7 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), + ...(expectEnterpriseSearch ? [actions.ui.get('enterpriseSearch', 'all')] : []), ]); expect(actual).toHaveProperty(`${group}.read`, [actions.login, actions.version]); }); @@ -840,6 +846,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('enterpriseSearch', 'all'), actions.ui.get('foo', 'foo'), ]); expect(actual).toHaveProperty('global.read', [ @@ -991,6 +998,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('enterpriseSearch', 'all'), actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), @@ -1189,6 +1197,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('enterpriseSearch', 'all'), ]); expect(actual).toHaveProperty('global.read', [actions.login, actions.version]); @@ -1315,6 +1324,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('enterpriseSearch', 'all'), actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), @@ -1477,6 +1487,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('enterpriseSearch', 'all'), ]); expect(actual).toHaveProperty('global.read', [actions.login, actions.version]); @@ -1592,6 +1603,7 @@ describe('subFeatures', () => { actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('enterpriseSearch', 'all'), actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.ts index 5a15290a7f1a29..f9ee5fc7501275 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.ts @@ -101,6 +101,7 @@ export function privilegesFactory( actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('enterpriseSearch', 'all'), ...allActions, ], read: [actions.login, actions.version, ...readActions], diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 13d7c62316040b..1ea16a2a9940c9 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -7,6 +7,40 @@ } } }, + "app_search": { + "properties": { + "ui_viewed": { + "properties": { + "setup_guide": { + "type": "long" + }, + "engines_overview": { + "type": "long" + } + } + }, + "ui_error": { + "properties": { + "cannot_connect": { + "type": "long" + } + } + }, + "ui_clicked": { + "properties": { + "create_first_engine_button": { + "type": "long" + }, + "header_launch_button": { + "type": "long" + }, + "engine_table_link": { + "type": "long" + } + } + } + } + }, "fileUploadTelemetry": { "properties": { "filesUploadedTotalCount": { diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 29be6d826c1bc4..ee8af9e040401a 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -53,6 +53,7 @@ const onlyNotInCoverageTests = [ require.resolve('../test/reporting_api_integration/config.js'), require.resolve('../test/functional_embedded/config.ts'), require.resolve('../test/ingest_manager_api_integration/config.ts'), + require.resolve('../test/functional_enterprise_search/without_host_configured.config.ts'), ]; require('@kbn/plugin-helpers').babelRegister(); diff --git a/x-pack/test/api_integration/apis/features/features/features.ts b/x-pack/test/api_integration/apis/features/features/features.ts index 11fb9b2de71991..df6eca795f8019 100644 --- a/x-pack/test/api_integration/apis/features/features/features.ts +++ b/x-pack/test/api_integration/apis/features/features/features.ts @@ -97,6 +97,7 @@ export default function ({ getService }: FtrProviderContext) { 'visualize', 'dashboard', 'dev_tools', + 'enterpriseSearch', 'advancedSettings', 'indexPatterns', 'timelion', diff --git a/x-pack/test/functional_enterprise_search/README.md b/x-pack/test/functional_enterprise_search/README.md new file mode 100644 index 00000000000000..63d13cbac7020c --- /dev/null +++ b/x-pack/test/functional_enterprise_search/README.md @@ -0,0 +1,41 @@ +# Enterprise Search Functional E2E Tests + +## Running these tests + +Follow the [Functional Test Runner instructions](https://www.elastic.co/guide/en/kibana/current/development-functional-tests.html#_running_functional_tests). + +There are two suites available to run, a suite that requires a Kibana instance without an `enterpriseSearch.host` +configured, and one that does. The later also [requires a running Enterprise Search instance](#enterprise-search-requirement), and a Private API key +from that instance set in an Environment variable. + +Ex. + +```sh +# Run specs from the x-pack directory +cd x-pack + +# Run tests that do not require enterpriseSearch.host variable +node scripts/functional_tests --config test/functional_enterprise_search/without_host_configured.config.ts + +# Run tests that require enterpriseSearch.host variable +APP_SEARCH_API_KEY=[use private key from local App Search instance here] node scripts/functional_tests --config test/functional_enterprise_search/with_host_configured.config.ts +``` + +## Enterprise Search Requirement + +The `with_host_configured` tests will not currently start an instance of App Search automatically. As such, they are not run as part of CI and are most useful for local regression testing. + +The easiest way to start Enterprise Search for these tests is to check out the `ent-search` project +and use the following script. + +```sh +cd script/stack_scripts +/start-with-license-and-expiration.sh platinum 500000 +``` + +Requirements for Enterprise Search: + +- Running on port 3002 against a separate Elasticsearch cluster. +- Elasticsearch must have a platinum or greater level license (or trial). +- Must have Standard or Native Auth configured with an `enterprise_search` user with password `changeme`. +- There should be NO existing Engines or Meta Engines. diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts new file mode 100644 index 00000000000000..e4ebd61c0692a2 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts @@ -0,0 +1,75 @@ +/* + * 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 expect from '@kbn/expect'; +import { EsArchiver } from 'src/es_archiver'; +import { AppSearchService, IEngine } from '../../../../services/app_search_service'; +import { Browser } from '../../../../../../../test/functional/services/common'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; + +export default function enterpriseSearchSetupEnginesTests({ + getService, + getPageObjects, +}: FtrProviderContext) { + const esArchiver = getService('esArchiver') as EsArchiver; + const browser = getService('browser') as Browser; + const retry = getService('retry'); + const appSearch = getService('appSearch') as AppSearchService; + + const PageObjects = getPageObjects(['appSearch', 'security']); + + describe('Engines Overview', function () { + let engine1: IEngine; + let engine2: IEngine; + let metaEngine: IEngine; + + before(async () => { + await esArchiver.load('empty_kibana'); + engine1 = await appSearch.createEngine(); + engine2 = await appSearch.createEngine(); + metaEngine = await appSearch.createMetaEngine([engine1.name, engine2.name]); + }); + + after(async () => { + await esArchiver.unload('empty_kibana'); + appSearch.destroyEngine(engine1.name); + appSearch.destroyEngine(engine2.name); + appSearch.destroyEngine(metaEngine.name); + }); + + describe('when an enterpriseSearch.host is configured', () => { + it('navigating to the enterprise_search plugin will redirect a user to the App Search Engines Overview page', async () => { + await PageObjects.security.forceLogout(); + const { user, password } = appSearch.getEnterpriseSearchUser(); + await PageObjects.security.login(user, password, { + expectSpaceSelector: false, + }); + + await PageObjects.appSearch.navigateToPage(); + await retry.try(async function () { + const currentUrl = await browser.getCurrentUrl(); + expect(currentUrl).to.contain('/app_search'); + }); + }); + + it('lists engines', async () => { + const engineLinks = await PageObjects.appSearch.getEngineLinks(); + const engineLinksText = await Promise.all(engineLinks.map((l) => l.getVisibleText())); + + expect(engineLinksText.includes(engine1.name)).to.equal(true); + expect(engineLinksText.includes(engine2.name)).to.equal(true); + }); + + it('lists meta engines', async () => { + const metaEngineLinks = await PageObjects.appSearch.getMetaEngineLinks(); + const metaEngineLinksText = await Promise.all( + metaEngineLinks.map((l) => l.getVisibleText()) + ); + expect(metaEngineLinksText.includes(metaEngine.name)).to.equal(true); + }); + }); + }); +} diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/index.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/index.ts new file mode 100644 index 00000000000000..ac4984e0db0190 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/index.ts @@ -0,0 +1,13 @@ +/* + * 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 { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('Enterprise Search', function () { + loadTestFile(require.resolve('./app_search/engines')); + }); +} diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts new file mode 100644 index 00000000000000..1d478c6baf29cb --- /dev/null +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts @@ -0,0 +1,36 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; + +export default function enterpriseSearchSetupGuideTests({ + getService, + getPageObjects, +}: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const browser = getService('browser'); + const retry = getService('retry'); + + const PageObjects = getPageObjects(['appSearch']); + + describe('Setup Guide', function () { + before(async () => await esArchiver.load('empty_kibana')); + after(async () => { + await esArchiver.unload('empty_kibana'); + }); + + describe('when no enterpriseSearch.host is configured', () => { + it('navigating to the enterprise_search plugin will redirect a user to the setup guide', async () => { + await PageObjects.appSearch.navigateToPage(); + await retry.try(async function () { + const currentUrl = await browser.getCurrentUrl(); + expect(currentUrl).to.contain('/app_search/setup_guide'); + }); + }); + }); + }); +} diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts new file mode 100644 index 00000000000000..31a92e752fcf4e --- /dev/null +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('Enterprise Search', function () { + this.tags('ciGroup10'); + + loadTestFile(require.resolve('./app_search/setup_guide')); + }); +} diff --git a/x-pack/test/functional_enterprise_search/base_config.ts b/x-pack/test/functional_enterprise_search/base_config.ts new file mode 100644 index 00000000000000..f737b6cd4b5f45 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/base_config.ts @@ -0,0 +1,20 @@ +/* + * 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 { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import { pageObjects } from './page_objects'; +import { services } from './services'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const xPackFunctionalConfig = await readConfigFile(require.resolve('../functional/config')); + + return { + // default to the xpack functional config + ...xPackFunctionalConfig.getAll(), + services, + pageObjects, + }; +} diff --git a/x-pack/test/functional_enterprise_search/ftr_provider_context.d.ts b/x-pack/test/functional_enterprise_search/ftr_provider_context.d.ts new file mode 100644 index 00000000000000..bb257cdcbfe1b5 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/ftr_provider_context.d.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 { GenericFtrProviderContext } from '@kbn/test/types/ftr'; + +import { pageObjects } from './page_objects'; +import { services } from './services'; + +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/functional_enterprise_search/page_objects/app_search.ts b/x-pack/test/functional_enterprise_search/page_objects/app_search.ts new file mode 100644 index 00000000000000..d845a1935a1496 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/page_objects/app_search.ts @@ -0,0 +1,30 @@ +/* + * 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 { FtrProviderContext } from '../ftr_provider_context'; +import { TestSubjects } from '../../../../test/functional/services/common'; +import { WebElementWrapper } from '../../../../test/functional/services/lib/web_element_wrapper'; + +export function AppSearchPageProvider({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['common']); + const testSubjects = getService('testSubjects') as TestSubjects; + + return { + async navigateToPage(): Promise { + return await PageObjects.common.navigateToApp('enterprise_search/app_search'); + }, + + async getEngineLinks(): Promise { + const engines = await testSubjects.find('appSearchEngines'); + return await testSubjects.findAllDescendant('engineNameLink', engines); + }, + + async getMetaEngineLinks(): Promise { + const metaEngines = await testSubjects.find('appSearchMetaEngines'); + return await testSubjects.findAllDescendant('engineNameLink', metaEngines); + }, + }; +} diff --git a/x-pack/test/functional_enterprise_search/page_objects/index.ts b/x-pack/test/functional_enterprise_search/page_objects/index.ts new file mode 100644 index 00000000000000..009fb264824195 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/page_objects/index.ts @@ -0,0 +1,13 @@ +/* + * 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 { pageObjects as basePageObjects } from '../../functional/page_objects'; +import { AppSearchPageProvider } from './app_search'; + +export const pageObjects = { + ...basePageObjects, + appSearch: AppSearchPageProvider, +}; diff --git a/x-pack/test/functional_enterprise_search/services/app_search_client.ts b/x-pack/test/functional_enterprise_search/services/app_search_client.ts new file mode 100644 index 00000000000000..fbd15b83f97ea7 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/services/app_search_client.ts @@ -0,0 +1,121 @@ +/* + * 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 http from 'http'; + +/** + * A simple request client for making API calls to the App Search API + */ +const makeRequest = (method: string, path: string, body?: object): Promise => { + return new Promise(function (resolve, reject) { + const APP_SEARCH_API_KEY = process.env.APP_SEARCH_API_KEY; + + if (!APP_SEARCH_API_KEY) { + throw new Error('Please provide a valid APP_SEARCH_API_KEY. See README for more details.'); + } + + let postData; + + if (body) { + postData = JSON.stringify(body); + } + + const req = http.request( + { + method, + hostname: 'localhost', + port: 3002, + path, + agent: false, // Create a new agent just for this one request + headers: { + Authorization: `Bearer ${APP_SEARCH_API_KEY}`, + 'Content-Type': 'application/json', + ...(!!postData && { 'Content-Length': Buffer.byteLength(postData) }), + }, + }, + (res) => { + const bodyChunks: Uint8Array[] = []; + res.on('data', function (chunk) { + bodyChunks.push(chunk); + }); + + res.on('end', function () { + let responseBody; + try { + responseBody = JSON.parse(Buffer.concat(bodyChunks).toString()); + } catch (e) { + reject(e); + } + + if (res.statusCode && res.statusCode > 299) { + reject('Error calling App Search API: ' + JSON.stringify(responseBody)); + } + + resolve(responseBody); + }); + } + ); + + req.on('error', (e) => { + reject(e); + }); + + if (postData) { + req.write(postData); + } + req.end(); + }); +}; + +export interface IEngine { + name: string; +} + +export const createEngine = async (engineName: string): Promise => { + return await makeRequest('POST', '/api/as/v1/engines', { name: engineName }); +}; + +export const destroyEngine = async (engineName: string): Promise => { + return await makeRequest('DELETE', `/api/as/v1/engines/${engineName}`); +}; + +export const createMetaEngine = async ( + engineName: string, + sourceEngines: string[] +): Promise => { + return await makeRequest('POST', '/api/as/v1/engines', { + name: engineName, + type: 'meta', + source_engines: sourceEngines, + }); +}; + +export interface ISearchResponse { + results: object[]; +} + +const search = async (engineName: string): Promise => { + return await makeRequest('POST', `/api/as/v1/engines/${engineName}/search`, { query: '' }); +}; + +// Since the App Search API does not issue document receipts, the only way to tell whether or not documents +// are fully indexed is to poll the search endpoint. +export const waitForIndexedDocs = (engineName: string) => { + return new Promise(async function (resolve) { + let isReady = false; + while (!isReady) { + const response = await search(engineName); + if (response.results && response.results.length > 0) { + isReady = true; + resolve(); + } + } + }); +}; + +export const indexData = async (engineName: string, docs: object[]) => { + return await makeRequest('POST', `/api/as/v1/engines/${engineName}/documents`, docs); +}; diff --git a/x-pack/test/functional_enterprise_search/services/app_search_service.ts b/x-pack/test/functional_enterprise_search/services/app_search_service.ts new file mode 100644 index 00000000000000..9a43783402f4b3 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/services/app_search_service.ts @@ -0,0 +1,77 @@ +/* + * 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 { FtrProviderContext } from '../ftr_provider_context'; + +const ENTERPRISE_SEARCH_USER = 'enterprise_search'; +const ENTERPRISE_SEARCH_PASSWORD = 'changeme'; +import { + createEngine, + createMetaEngine, + indexData, + waitForIndexedDocs, + destroyEngine, + IEngine, +} from './app_search_client'; + +export interface IUser { + user: string; + password: string; +} +export { IEngine }; + +export class AppSearchService { + getEnterpriseSearchUser(): IUser { + return { + user: ENTERPRISE_SEARCH_USER, + password: ENTERPRISE_SEARCH_PASSWORD, + }; + } + + createEngine(): Promise { + const engineName = `test-engine-${new Date().getTime()}`; + return createEngine(engineName); + } + + async createEngineWithDocs(): Promise { + const engine = await this.createEngine(); + const docs = [ + { id: 1, name: 'doc1' }, + { id: 2, name: 'doc2' }, + { id: 3, name: 'doc2' }, + ]; + await indexData(engine.name, docs); + await waitForIndexedDocs(engine.name); + return engine; + } + + createMetaEngine(sourceEngines: string[]): Promise { + const engineName = `test-meta-engine-${new Date().getTime()}`; + return createMetaEngine(engineName, sourceEngines); + } + + destroyEngine(engineName: string) { + return destroyEngine(engineName); + } +} + +export async function AppSearchServiceProvider({ getService }: FtrProviderContext) { + const lifecycle = getService('lifecycle'); + const security = getService('security'); + + lifecycle.beforeTests.add(async () => { + // The App Search plugin passes through the current user name and password + // through on the API call to App Search. Therefore, we need to be signed + // in as the enterprise_search user in order for this plugin to work. + await security.user.create(ENTERPRISE_SEARCH_USER, { + password: ENTERPRISE_SEARCH_PASSWORD, + roles: ['kibana_admin'], + full_name: ENTERPRISE_SEARCH_USER, + }); + }); + + return new AppSearchService(); +} diff --git a/x-pack/test/functional_enterprise_search/services/index.ts b/x-pack/test/functional_enterprise_search/services/index.ts new file mode 100644 index 00000000000000..1715c98677ac6f --- /dev/null +++ b/x-pack/test/functional_enterprise_search/services/index.ts @@ -0,0 +1,13 @@ +/* + * 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 { services as functionalServices } from '../../functional/services'; +import { AppSearchServiceProvider } from './app_search_service'; + +export const services = { + ...functionalServices, + appSearch: AppSearchServiceProvider, +}; diff --git a/x-pack/test/functional_enterprise_search/with_host_configured.config.ts b/x-pack/test/functional_enterprise_search/with_host_configured.config.ts new file mode 100644 index 00000000000000..f425f806f4bcd5 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/with_host_configured.config.ts @@ -0,0 +1,31 @@ +/* + * 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 { resolve } from 'path'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const baseConfig = await readConfigFile(require.resolve('./base_config')); + + return { + // default to the xpack functional config + ...baseConfig.getAll(), + + testFiles: [resolve(__dirname, './apps/enterprise_search/with_host_configured')], + + junit: { + reportName: 'X-Pack Enterprise Search Functional Tests with Host Configured', + }, + + kbnTestServer: { + ...baseConfig.get('kbnTestServer'), + serverArgs: [ + ...baseConfig.get('kbnTestServer.serverArgs'), + '--enterpriseSearch.host=http://localhost:3002', + ], + }, + }; +} diff --git a/x-pack/test/functional_enterprise_search/without_host_configured.config.ts b/x-pack/test/functional_enterprise_search/without_host_configured.config.ts new file mode 100644 index 00000000000000..0f2afd214abedf --- /dev/null +++ b/x-pack/test/functional_enterprise_search/without_host_configured.config.ts @@ -0,0 +1,23 @@ +/* + * 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 { resolve } from 'path'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const baseConfig = await readConfigFile(require.resolve('./base_config')); + + return { + // default to the xpack functional config + ...baseConfig.getAll(), + + testFiles: [resolve(__dirname, './apps/enterprise_search/without_host_configured')], + + junit: { + reportName: 'X-Pack Enterprise Search Functional Tests without Host Configured', + }, + }; +} diff --git a/x-pack/test/ui_capabilities/common/nav_links_builder.ts b/x-pack/test/ui_capabilities/common/nav_links_builder.ts index 405ef4dbdc5b1d..b20a499ba7e20d 100644 --- a/x-pack/test/ui_capabilities/common/nav_links_builder.ts +++ b/x-pack/test/ui_capabilities/common/nav_links_builder.ts @@ -15,6 +15,10 @@ export class NavLinksBuilder { management: { navLinkId: 'kibana:stack_management', }, + // TODO: Temp until navLinkIds fix is merged in + appSearch: { + navLinkId: 'appSearch', + }, }; } diff --git a/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts b/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts index f8f3f2be2b2ec1..0e0d46c6ce2cd2 100644 --- a/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts +++ b/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts @@ -32,17 +32,27 @@ export default function catalogueTests({ getService }: FtrProviderContext) { break; } case 'global_all at everything_space': - case 'dual_privileges_all at everything_space': + case 'dual_privileges_all at everything_space': { + expect(uiCapabilities.success).to.be(true); + expect(uiCapabilities.value).to.have.property('catalogue'); + // everything except ml and monitoring is enabled + const expected = mapValues( + uiCapabilities.value!.catalogue, + (enabled, catalogueId) => catalogueId !== 'ml' && catalogueId !== 'monitoring' + ); + expect(uiCapabilities.value!.catalogue).to.eql(expected); + break; + } case 'everything_space_all at everything_space': case 'global_read at everything_space': case 'dual_privileges_read at everything_space': case 'everything_space_read at everything_space': { expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('catalogue'); - // everything except ml and monitoring is enabled + // everything except ml and monitoring and enterprise search is enabled const expected = mapValues( uiCapabilities.value!.catalogue, - (enabled, catalogueId) => catalogueId !== 'ml' && catalogueId !== 'monitoring' + (enabled, catalogueId) => !['ml', 'monitoring', 'appSearch'].includes(catalogueId) ); expect(uiCapabilities.value!.catalogue).to.eql(expected); break; diff --git a/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts b/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts index 10ecf5d25d3469..08a7d789153e77 100644 --- a/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts +++ b/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts @@ -38,14 +38,20 @@ export default function navLinksTests({ getService }: FtrProviderContext) { break; case 'global_all at everything_space': case 'dual_privileges_all at everything_space': - case 'dual_privileges_read at everything_space': - case 'global_read at everything_space': + expect(uiCapabilities.success).to.be(true); + expect(uiCapabilities.value).to.have.property('navLinks'); + expect(uiCapabilities.value!.navLinks).to.eql( + navLinksBuilder.except('ml', 'monitoring') + ); + break; case 'everything_space_all at everything_space': + case 'global_read at everything_space': + case 'dual_privileges_read at everything_space': case 'everything_space_read at everything_space': expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('navLinks'); expect(uiCapabilities.value!.navLinks).to.eql( - navLinksBuilder.except('ml', 'monitoring') + navLinksBuilder.except('ml', 'monitoring', 'enterpriseSearch', 'appSearch') ); break; case 'superuser at nothing_space': diff --git a/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts b/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts index 52a1f30147b4ff..99f91407dc1d2b 100644 --- a/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts +++ b/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts @@ -32,9 +32,7 @@ export default function catalogueTests({ getService }: FtrProviderContext) { break; } case 'all': - case 'read': - case 'dual_privileges_all': - case 'dual_privileges_read': { + case 'dual_privileges_all': { expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('catalogue'); // everything except ml and monitoring is enabled @@ -45,6 +43,18 @@ export default function catalogueTests({ getService }: FtrProviderContext) { expect(uiCapabilities.value!.catalogue).to.eql(expected); break; } + case 'read': + case 'dual_privileges_read': { + expect(uiCapabilities.success).to.be(true); + expect(uiCapabilities.value).to.have.property('catalogue'); + // everything except ml and monitoring and enterprise search is enabled + const expected = mapValues( + uiCapabilities.value!.catalogue, + (enabled, catalogueId) => !['ml', 'monitoring', 'appSearch'].includes(catalogueId) + ); + expect(uiCapabilities.value!.catalogue).to.eql(expected); + break; + } case 'foo_all': case 'foo_read': { expect(uiCapabilities.success).to.be(true); diff --git a/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts b/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts index fe9ffa9286de83..d3bd2e1afd357c 100644 --- a/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts +++ b/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts @@ -37,15 +37,21 @@ export default function navLinksTests({ getService }: FtrProviderContext) { expect(uiCapabilities.value!.navLinks).to.eql(navLinksBuilder.all()); break; case 'all': - case 'read': case 'dual_privileges_all': - case 'dual_privileges_read': expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('navLinks'); expect(uiCapabilities.value!.navLinks).to.eql( navLinksBuilder.except('ml', 'monitoring') ); break; + case 'read': + case 'dual_privileges_read': + expect(uiCapabilities.success).to.be(true); + expect(uiCapabilities.value).to.have.property('navLinks'); + expect(uiCapabilities.value!.navLinks).to.eql( + navLinksBuilder.except('ml', 'monitoring', 'appSearch') + ); + break; case 'foo_all': case 'foo_read': expect(uiCapabilities.success).to.be(true); From 633968e0536ab899ddd1c7837ee934a51ebeea58 Mon Sep 17 00:00:00 2001 From: Dmitry Lemeshko Date: Thu, 9 Jul 2020 22:12:52 +0200 Subject: [PATCH 20/49] Remove IE support in functional tests (#71285) * [ftr] remove ie support * remove ie integration tests config --- .../lib/config/schema.ts | 2 +- test/functional/apps/home/_navigation.ts | 10 -- test/functional/config.ie.js | 52 ---------- test/functional/page_objects/time_picker.ts | 7 -- test/functional/services/common/browser.ts | 97 +++++-------------- .../web_element_wrapper.ts | 55 +++-------- test/functional/services/remote/browsers.ts | 1 - test/functional/services/remote/remote.ts | 9 +- test/functional/services/remote/webdriver.ts | 36 +------ x-pack/test/functional/config.ie.js | 74 -------------- ...ig.stack_functional_integration_base_ie.js | 18 ---- 11 files changed, 39 insertions(+), 322 deletions(-) delete mode 100644 test/functional/config.ie.js delete mode 100644 x-pack/test/functional/config.ie.js delete mode 100644 x-pack/test/stack_functional_integration/configs/config.stack_functional_integration_base_ie.js diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts b/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts index 6cbdc5ec7fc208..e1d3bf1a8d9016 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts @@ -148,7 +148,7 @@ export const schema = Joi.object() browser: Joi.object() .keys({ - type: Joi.string().valid('chrome', 'firefox', 'ie', 'msedge').default('chrome'), + type: Joi.string().valid('chrome', 'firefox', 'msedge').default('chrome'), logPollingMs: Joi.number().default(100), acceptInsecureCerts: Joi.boolean().default(false), diff --git a/test/functional/apps/home/_navigation.ts b/test/functional/apps/home/_navigation.ts index cfe4f9cc3e014b..b8fa5b184cd1f4 100644 --- a/test/functional/apps/home/_navigation.ts +++ b/test/functional/apps/home/_navigation.ts @@ -26,21 +26,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'header', 'home', 'timePicker']); const appsMenu = getService('appsMenu'); const esArchiver = getService('esArchiver'); - const kibanaServer = getService('kibanaServer'); describe('Kibana browser back navigation should work', function describeIndexTests() { before(async () => { await esArchiver.loadIfNeeded('discover'); await esArchiver.loadIfNeeded('logstash_functional'); - if (browser.isInternetExplorer) { - await kibanaServer.uiSettings.replace({ 'state:storeInSessionStorage': false }); - } - }); - - after(async () => { - if (browser.isInternetExplorer) { - await kibanaServer.uiSettings.replace({ 'state:storeInSessionStorage': true }); - } }); it('detect navigate back issues', async () => { diff --git a/test/functional/config.ie.js b/test/functional/config.ie.js deleted file mode 100644 index bc47ce707003eb..00000000000000 --- a/test/functional/config.ie.js +++ /dev/null @@ -1,52 +0,0 @@ -/* - * 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 default async function ({ readConfigFile }) { - const defaultConfig = await readConfigFile(require.resolve('./config')); - - return { - ...defaultConfig.getAll(), - - browser: { - type: 'ie', - }, - - junit: { - reportName: 'Internet Explorer UI Functional Tests', - }, - - uiSettings: { - defaults: { - 'accessibility:disableAnimations': true, - 'dateFormat:tz': 'UTC', - 'state:storeInSessionStorage': true, - 'notifications:lifetime:info': 10000, - }, - }, - - kbnTestServer: { - ...defaultConfig.get('kbnTestServer'), - serverArgs: [ - ...defaultConfig.get('kbnTestServer.serverArgs'), - '--csp.strict=false', - '--telemetry.optIn=false', - ], - }, - }; -} diff --git a/test/functional/page_objects/time_picker.ts b/test/functional/page_objects/time_picker.ts index 7ef291c8c7005f..8a726cee444c16 100644 --- a/test/functional/page_objects/time_picker.ts +++ b/test/functional/page_objects/time_picker.ts @@ -98,13 +98,6 @@ export function TimePickerProvider({ getService, getPageObjects }: FtrProviderCo const input = await testSubjects.find(dataTestSubj); await input.clearValue(); await input.type(value); - } else if (browser.isInternetExplorer) { - const input = await testSubjects.find(dataTestSubj); - const currentValue = await input.getAttribute('value'); - await input.type(browser.keys.ARROW_RIGHT.repeat(currentValue.length)); - await input.type(browser.keys.BACK_SPACE.repeat(currentValue.length)); - await input.type(value); - await input.click(); } else { await testSubjects.setValue(dataTestSubj, value); } diff --git a/test/functional/services/common/browser.ts b/test/functional/services/common/browser.ts index 2d35551b04808f..c38ac771e41625 100644 --- a/test/functional/services/common/browser.ts +++ b/test/functional/services/common/browser.ts @@ -34,8 +34,6 @@ export async function BrowserProvider({ getService }: FtrProviderContext) { const log = getService('log'); const { driver, browserType } = await getService('__webdriver__').init(); - const isW3CEnabled = (driver as any).executor_.w3c === true; - return new (class BrowserService { /** * Keyboard events @@ -53,19 +51,12 @@ export async function BrowserProvider({ getService }: FtrProviderContext) { public readonly isFirefox: boolean = browserType === Browsers.Firefox; - public readonly isInternetExplorer: boolean = browserType === Browsers.InternetExplorer; - - /** - * Is WebDriver instance W3C compatible - */ - isW3CEnabled = isW3CEnabled; - /** * Returns instance of Actions API based on driver w3c flag * https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/lib/webdriver_exports_WebDriver.html#actions */ public getActions() { - return this.isW3CEnabled ? driver.actions() : driver.actions({ bridge: true }); + return driver.actions(); } /** @@ -164,12 +155,7 @@ export async function BrowserProvider({ getService }: FtrProviderContext) { */ public async getCurrentUrl() { // strip _t=Date query param when url is read - let current: string; - if (this.isInternetExplorer) { - current = await driver.executeScript('return window.document.location.href'); - } else { - current = await driver.getCurrentUrl(); - } + const current = await driver.getCurrentUrl(); const currentWithoutTime = modifyUrl(current, (parsed) => { delete (parsed.query as any)._t; return void 0; @@ -214,15 +200,8 @@ export async function BrowserProvider({ getService }: FtrProviderContext) { * @return {Promise} */ public async moveMouseTo(point: { x: number; y: number }): Promise { - if (this.isW3CEnabled) { - await this.getActions().move({ x: 0, y: 0 }).perform(); - await this.getActions().move({ x: point.x, y: point.y, origin: Origin.POINTER }).perform(); - } else { - await this.getActions() - .pause(this.getActions().mouse) - .move({ x: point.x, y: point.y, origin: Origin.POINTER }) - .perform(); - } + await this.getActions().move({ x: 0, y: 0 }).perform(); + await this.getActions().move({ x: point.x, y: point.y, origin: Origin.POINTER }).perform(); } /** @@ -237,44 +216,20 @@ export async function BrowserProvider({ getService }: FtrProviderContext) { from: { offset?: { x: any; y: any }; location: any }, to: { offset?: { x: any; y: any }; location: any } ) { - if (this.isW3CEnabled) { - // The offset should be specified in pixels relative to the center of the element's bounding box - const getW3CPoint = (data: any) => { - if (!data.offset) { - data.offset = {}; - } - return data.location instanceof WebElementWrapper - ? { x: data.offset.x || 0, y: data.offset.y || 0, origin: data.location._webElement } - : { x: data.location.x, y: data.location.y, origin: Origin.POINTER }; - }; - - const startPoint = getW3CPoint(from); - const endPoint = getW3CPoint(to); - await this.getActions().move({ x: 0, y: 0 }).perform(); - return await this.getActions().move(startPoint).press().move(endPoint).release().perform(); - } else { - // The offset should be specified in pixels relative to the top-left corner of the element's bounding box - const getOffset: any = (offset: { x: number; y: number }) => - offset ? { x: offset.x || 0, y: offset.y || 0 } : { x: 0, y: 0 }; - - if (from.location instanceof WebElementWrapper === false) { - throw new Error('Dragging point should be WebElementWrapper instance'); - } else if (typeof to.location.x === 'number') { - return await this.getActions() - .move({ origin: from.location._webElement }) - .press() - .move({ x: to.location.x, y: to.location.y, origin: Origin.POINTER }) - .release() - .perform(); - } else { - return await new LegacyActionSequence(driver) - .mouseMove(from.location._webElement, getOffset(from.offset)) - .mouseDown() - .mouseMove(to.location._webElement, getOffset(to.offset)) - .mouseUp() - .perform(); + // The offset should be specified in pixels relative to the center of the element's bounding box + const getW3CPoint = (data: any) => { + if (!data.offset) { + data.offset = {}; } - } + return data.location instanceof WebElementWrapper + ? { x: data.offset.x || 0, y: data.offset.y || 0, origin: data.location._webElement } + : { x: data.location.x, y: data.location.y, origin: Origin.POINTER }; + }; + + const startPoint = getW3CPoint(from); + const endPoint = getW3CPoint(to); + await this.getActions().move({ x: 0, y: 0 }).perform(); + return await this.getActions().move(startPoint).press().move(endPoint).release().perform(); } /** @@ -341,19 +296,11 @@ export async function BrowserProvider({ getService }: FtrProviderContext) { * @return {Promise} */ public async clickMouseButton(point: { x: number; y: number }) { - if (this.isW3CEnabled) { - await this.getActions().move({ x: 0, y: 0 }).perform(); - await this.getActions() - .move({ x: point.x, y: point.y, origin: Origin.POINTER }) - .click() - .perform(); - } else { - await this.getActions() - .pause(this.getActions().mouse) - .move({ x: point.x, y: point.y, origin: Origin.POINTER }) - .click() - .perform(); - } + await this.getActions().move({ x: 0, y: 0 }).perform(); + await this.getActions() + .move({ x: point.x, y: point.y, origin: Origin.POINTER }) + .click() + .perform(); } /** diff --git a/test/functional/services/lib/web_element_wrapper/web_element_wrapper.ts b/test/functional/services/lib/web_element_wrapper/web_element_wrapper.ts index 281a412653bd0a..5011235551bd82 100644 --- a/test/functional/services/lib/web_element_wrapper/web_element_wrapper.ts +++ b/test/functional/services/lib/web_element_wrapper/web_element_wrapper.ts @@ -47,7 +47,6 @@ const RETRY_CLICK_RETRY_ON_ERRORS = [ export class WebElementWrapper { private By = By; private Keys = Key; - public isW3CEnabled: boolean = (this.driver as any).executor_.w3c === true; public isChromium: boolean = [Browsers.Chrome, Browsers.ChromiumEdge].includes(this.browserType); public static create( @@ -141,7 +140,7 @@ export class WebElementWrapper { } private getActions() { - return this.isW3CEnabled ? this.driver.actions() : this.driver.actions({ bridge: true }); + return this.driver.actions(); } /** @@ -233,9 +232,6 @@ export class WebElementWrapper { * @default { withJS: false } */ async clearValue(options: ClearOptions = { withJS: false }) { - if (this.browserType === Browsers.InternetExplorer) { - return this.clearValueWithKeyboard(); - } await this.retryCall(async function clearValue(wrapper) { if (wrapper.isChromium || options.withJS) { // https://bugs.chromium.org/p/chromedriver/issues/detail?id=2702 @@ -252,16 +248,6 @@ export class WebElementWrapper { * @default { charByChar: false } */ async clearValueWithKeyboard(options: TypeOptions = { charByChar: false }) { - if (this.browserType === Browsers.InternetExplorer) { - const value = await this.getAttribute('value'); - // For IE testing, the text field gets clicked in the middle so - // first go HOME and then DELETE all chars - await this.pressKeys(this.Keys.HOME); - for (let i = 0; i <= value.length; i++) { - await this.pressKeys(this.Keys.DELETE); - } - return; - } if (options.charByChar === true) { const value = await this.getAttribute('value'); for (let i = 0; i <= value.length; i++) { @@ -429,19 +415,11 @@ export class WebElementWrapper { public async moveMouseTo(options = { xOffset: 0, yOffset: 0 }) { await this.retryCall(async function moveMouseTo(wrapper) { await wrapper.scrollIntoViewIfNecessary(); - if (wrapper.isW3CEnabled) { - await wrapper.getActions().move({ x: 0, y: 0 }).perform(); - await wrapper - .getActions() - .move({ x: options.xOffset, y: options.yOffset, origin: wrapper._webElement }) - .perform(); - } else { - await wrapper - .getActions() - .pause(wrapper.getActions().mouse) - .move({ x: options.xOffset, y: options.yOffset, origin: wrapper._webElement }) - .perform(); - } + await wrapper.getActions().move({ x: 0, y: 0 }).perform(); + await wrapper + .getActions() + .move({ x: options.xOffset, y: options.yOffset, origin: wrapper._webElement }) + .perform(); }); } @@ -456,21 +434,12 @@ export class WebElementWrapper { public async clickMouseButton(options = { xOffset: 0, yOffset: 0 }) { await this.retryCall(async function clickMouseButton(wrapper) { await wrapper.scrollIntoViewIfNecessary(); - if (wrapper.isW3CEnabled) { - await wrapper.getActions().move({ x: 0, y: 0 }).perform(); - await wrapper - .getActions() - .move({ x: options.xOffset, y: options.yOffset, origin: wrapper._webElement }) - .click() - .perform(); - } else { - await wrapper - .getActions() - .pause(wrapper.getActions().mouse) - .move({ x: options.xOffset, y: options.yOffset, origin: wrapper._webElement }) - .click() - .perform(); - } + await wrapper.getActions().move({ x: 0, y: 0 }).perform(); + await wrapper + .getActions() + .move({ x: options.xOffset, y: options.yOffset, origin: wrapper._webElement }) + .click() + .perform(); }); } diff --git a/test/functional/services/remote/browsers.ts b/test/functional/services/remote/browsers.ts index aa6e364d0a09d0..f7942e708a3bb1 100644 --- a/test/functional/services/remote/browsers.ts +++ b/test/functional/services/remote/browsers.ts @@ -20,6 +20,5 @@ export enum Browsers { Chrome = 'chrome', Firefox = 'firefox', - InternetExplorer = 'ie', ChromiumEdge = 'msedge', } diff --git a/test/functional/services/remote/remote.ts b/test/functional/services/remote/remote.ts index 99643929c4682c..a45403e31095c7 100644 --- a/test/functional/services/remote/remote.ts +++ b/test/functional/services/remote/remote.ts @@ -64,15 +64,12 @@ export async function RemoteProvider({ getService }: FtrProviderContext) { }; const { driver, consoleLog$ } = await initWebDriver(log, browserType, lifecycle, browserConfig); - const isW3CEnabled = (driver as any).executor_.w3c; - const caps = await driver.getCapabilities(); - const browserVersion = caps.get(isW3CEnabled ? 'browserVersion' : 'version'); log.info( - `Remote initialized: ${caps.get( - 'browserName' - )} ${browserVersion}, w3c compliance=${isW3CEnabled}, collectingCoverage=${collectCoverage}` + `Remote initialized: ${caps.get('browserName')} ${caps.get( + 'browserVersion' + )}, collectingCoverage=${collectCoverage}` ); if ([Browsers.Chrome, Browsers.ChromiumEdge].includes(browserType)) { diff --git a/test/functional/services/remote/webdriver.ts b/test/functional/services/remote/webdriver.ts index 78f659a064a0c0..c5613b9e27094a 100644 --- a/test/functional/services/remote/webdriver.ts +++ b/test/functional/services/remote/webdriver.ts @@ -17,7 +17,7 @@ * under the License. */ -import { delimiter, resolve } from 'path'; +import { resolve } from 'path'; import Fs from 'fs'; import * as Rx from 'rxjs'; @@ -279,40 +279,6 @@ async function attemptToCreateCommand( }; } - case 'ie': { - // https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/ie_exports_Options.html - const driverPath = require.resolve('iedriver/lib/iedriver'); - process.env.PATH = driverPath + delimiter + process.env.PATH; - - const ieCapabilities = Capabilities.ie(); - ieCapabilities.set('se:ieOptions', { - 'ie.ensureCleanSession': true, - ignoreProtectedModeSettings: true, - ignoreZoomSetting: false, // requires us to have 100% zoom level - nativeEvents: true, // need this for values to stick but it requires 100% scaling and window focus - requireWindowFocus: true, - logLevel: 'TRACE', - }); - - let session; - if (remoteSessionUrl) { - session = await new Builder() - .forBrowser(browserType) - .withCapabilities(ieCapabilities) - .usingServer(remoteSessionUrl) - .build(); - } else { - session = await new Builder() - .forBrowser(browserType) - .withCapabilities(ieCapabilities) - .build(); - } - return { - session, - consoleLog$: Rx.EMPTY, - }; - } - default: throw new Error(`${browserType} is not supported yet`); } diff --git a/x-pack/test/functional/config.ie.js b/x-pack/test/functional/config.ie.js deleted file mode 100644 index 1289bb723cfec1..00000000000000 --- a/x-pack/test/functional/config.ie.js +++ /dev/null @@ -1,74 +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 default async function ({ readConfigFile }) { - const defaultConfig = await readConfigFile(require.resolve('./config')); - - return { - ...defaultConfig.getAll(), - //csp.strict: false - // testFiles: [ - // require.resolve(__dirname, './apps/advanced_settings'), - // require.resolve(__dirname, './apps/canvas'), - // require.resolve(__dirname, './apps/graph'), - // require.resolve(__dirname, './apps/monitoring'), - // require.resolve(__dirname, './apps/watcher'), - // require.resolve(__dirname, './apps/dashboard'), - // require.resolve(__dirname, './apps/dashboard_mode'), - // require.resolve(__dirname, './apps/discover'), - // require.resolve(__dirname, './apps/security'), - // require.resolve(__dirname, './apps/spaces'), - // require.resolve(__dirname, './apps/lens'), - // require.resolve(__dirname, './apps/logstash'), - // require.resolve(__dirname, './apps/grok_debugger'), - // require.resolve(__dirname, './apps/infra'), - // require.resolve(__dirname, './apps/ml'), - // require.resolve(__dirname, './apps/rollup_job'), - // require.resolve(__dirname, './apps/maps'), - // require.resolve(__dirname, './apps/status_page'), - // require.resolve(__dirname, './apps/timelion'), - // require.resolve(__dirname, './apps/upgrade_assistant'), - // require.resolve(__dirname, './apps/visualize'), - // require.resolve(__dirname, './apps/uptime'), - // require.resolve(__dirname, './apps/saved_objects_management'), - // require.resolve(__dirname, './apps/dev_tools'), - // require.resolve(__dirname, './apps/apm'), - // require.resolve(__dirname, './apps/index_patterns'), - // require.resolve(__dirname, './apps/index_management'), - // require.resolve(__dirname, './apps/index_lifecycle_management'), - // require.resolve(__dirname, './apps/snapshot_restore'), - // require.resolve(__dirname, './apps/cross_cluster_replication'), - // require.resolve(__dirname, './apps/remote_clusters'), - // // This license_management file must be last because it is destructive. - // require.resolve(__dirname, './apps/license_management'), - // ], - - browser: { - type: 'ie', - }, - - junit: { - reportName: 'Internet Explorer UI Functional X-Pack Tests', - }, - - uiSettings: { - defaults: { - 'accessibility:disableAnimations': true, - 'dateFormat:tz': 'UTC', - 'state:storeInSessionStorage': true, - }, - }, - - kbnTestServer: { - ...defaultConfig.get('kbnTestServer'), - serverArgs: [ - ...defaultConfig.get('kbnTestServer.serverArgs'), - '--csp.strict=false', - '--telemetry.optIn=false', - ], - }, - }; -} diff --git a/x-pack/test/stack_functional_integration/configs/config.stack_functional_integration_base_ie.js b/x-pack/test/stack_functional_integration/configs/config.stack_functional_integration_base_ie.js deleted file mode 100644 index 933a59e4e25b90..00000000000000 --- a/x-pack/test/stack_functional_integration/configs/config.stack_functional_integration_base_ie.js +++ /dev/null @@ -1,18 +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 default async ({ readConfigFile }) => { - const baseConfigs = await readConfigFile( - require.resolve('./config.stack_functional_integration_base.js') - ); - return { - ...baseConfigs.getAll(), - browser: { - type: 'ie', - }, - security: { disableTestUser: true }, - }; -}; From 5e9f333fca3ef45fd4c0fa6f1624ab5f4117876b Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Thu, 9 Jul 2020 13:19:52 -0700 Subject: [PATCH 21/49] [DOCS] Clarify trial subscription levels (#70636) --- docs/management/images/management-license.png | Bin 215829 -> 138156 bytes docs/management/managing-licenses.asciidoc | 21 +++++++++--------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/docs/management/images/management-license.png b/docs/management/images/management-license.png index 3347aec8632e4fa332caf49d9662be4ddf82f0bf..8df9402939b2e933b4e6a61e245c1d02bcc2d3a2 100644 GIT binary patch literal 138156 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

^k_6I?;`hKlT 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�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%0Ak`o>O~G!mnEpidf-!oIcm04&Mx{ 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�B0KZ;)7|iYtwUUlf8Iu-A=z>I@p}`BIpp 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

kXLtgnT5X}9!q;%J`RseY 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 I&MBb@0PwD*2LJ#7 diff --git a/docs/management/managing-licenses.asciidoc b/docs/management/managing-licenses.asciidoc index 6cd6657a0aaeb8..99cfd12eeade9c 100644 --- a/docs/management/managing-licenses.asciidoc +++ b/docs/management/managing-licenses.asciidoc @@ -1,28 +1,27 @@ [[managing-licenses]] == License Management -When you install the default distribution of {kib}, you receive a basic license -with no expiration date. For the full list of free features that are included in -the basic license, refer to https://www.elastic.co/subscriptions[the subscription page]. +When you install the default distribution of {kib}, you receive free features +with no expiration date. For the full list of features, refer to +{subscriptions}. -If you want to try out the full set of platinum features, you can activate a -30-day trial license. To view the -status of your license, start a trial, or install a new license, open the menu, then go to *Stack Management > {es} > License Management*. +If you want to try out the full set of features, you can activate a free 30-day +trial. To view the status of your license, start a trial, or install a new +license, open the menu, then go to *Stack Management > {es} > License Management*. NOTE: You can start a trial only if your cluster has not already activated a trial license for the current major product version. For example, if you have already activated a trial for 6.0, you cannot start a new trial until -7.0. You can, however, contact `info@elastic.co` to request an extended trial -license. +7.0. You can, however, request an extended trial at {extendtrial}. When you activate a new license level, new features appear in *Stack Management*. [role="screenshot"] image::images/management-license.png[] -At the end of the trial period, the platinum features operate in a -<>. You can revert to a basic license, -extend the trial, or purchase a subscription. +At the end of the trial period, some features operate in a +<>. You can revert to Basic, extend the trial, +or purchase a subscription. TIP: If {security-features} are enabled, unless you have a trial license, you must configure Transport Layer Security (TLS) in {es}. From c7f3d9219f433078d52d398b1ad2ef37811e43ee Mon Sep 17 00:00:00 2001 From: Brent Kimmel Date: Thu, 9 Jul 2020 16:37:58 -0400 Subject: [PATCH 22/49] [Security_Solution][Resolver]Add beta badge to Resolver panel (#71183) * Add beta badge to Resolver panel --- .../view/panels/panel_content_utilities.tsx | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx index 517b8478556478..b5c4e6481216c1 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx @@ -5,7 +5,7 @@ */ import { i18n } from '@kbn/i18n'; -import { EuiBreadcrumbs, Breadcrumb, EuiCode } from '@elastic/eui'; +import { EuiBreadcrumbs, Breadcrumb, EuiCode, EuiBetaBadge } from '@elastic/eui'; import styled from 'styled-components'; import React, { memo } from 'react'; import { useResolverTheme } from '../assets'; @@ -19,6 +19,10 @@ export const BoldCode = styled(EuiCode)` } `; +const BetaHeader = styled(`header`)` + margin-bottom: 1em; +`; + /** * The two query parameters we read/write on to control which view the table presents: */ @@ -40,6 +44,13 @@ const ThemedBreadcrumbs = styled(EuiBreadcrumbs)<{ background: string; text: str } `; +const betaBadgeLabel = i18n.translate( + 'xpack.securitySolution.enpdoint.resolver.panelutils.betaBadgeLabel', + { + defaultMessage: 'BETA', + } +); + /** * Breadcrumb menu with adjustments per direction from UX team */ @@ -54,12 +65,17 @@ export const StyledBreadcrumbs = memo(function StyledBreadcrumbs({ colorMap: { resolverBreadcrumbBackground, resolverEdgeText }, } = useResolverTheme(); return ( - + <> + + + + + ); }); From a32b9e89b65314d5277d15fd7c522371290a9802 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Thu, 9 Jul 2020 16:50:00 -0400 Subject: [PATCH 23/49] Support multiple features declaring same properties (#71106) Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com> Co-authored-by: Elastic Machine --- .../capabilities_switcher.test.ts | 51 ++++++++++++++++++- .../capabilities/capabilities_switcher.ts | 44 ++++++++++++---- 2 files changed, 83 insertions(+), 12 deletions(-) diff --git a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts index babd25dd3ec4b2..797d7fd1bdcc4e 100644 --- a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts +++ b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts @@ -59,6 +59,27 @@ const features = ([ }, }, }, + { + // feature 4 intentionally delcares the same items as feature 3 + id: 'feature_4', + name: 'Feature 4', + navLinkId: 'feature3', + app: ['feature3', 'feature3_app'], + catalogue: ['feature3Entry'], + management: { + kibana: ['indices'], + }, + privileges: { + all: { + app: [], + ui: [], + savedObject: { + all: [], + read: [], + }, + }, + }, + }, ] as unknown) as Feature[]; const buildCapabilities = () => @@ -73,6 +94,7 @@ const buildCapabilities = () => catalogue: { discover: true, visualize: false, + feature3Entry: true, }, management: { kibana: { @@ -217,11 +239,38 @@ describe('capabilitiesSwitcher', () => { expect(result).toEqual(expectedCapabilities); }); + it('does not disable catalogue, management, or app entries when they are shared with an enabled feature', async () => { + const space: Space = { + id: 'space', + name: '', + disabledFeatures: ['feature_3'], + }; + + const capabilities = buildCapabilities(); + + const { switcher } = setup(space); + const request = httpServerMock.createKibanaRequest(); + const result = await switcher(request, capabilities); + + const expectedCapabilities = buildCapabilities(); + + // These capabilities are shared by feature_4, which is enabled + expectedCapabilities.navLinks.feature3 = true; + expectedCapabilities.navLinks.feature3_app = true; + expectedCapabilities.catalogue.feature3Entry = true; + expectedCapabilities.management.kibana.indices = true; + // These capabilities are only exposed by feature_3, which is disabled + expectedCapabilities.feature_3.bar = false; + expectedCapabilities.feature_3.foo = false; + + expect(result).toEqual(expectedCapabilities); + }); + it('can disable everything', async () => { const space: Space = { id: 'space', name: '', - disabledFeatures: ['feature_1', 'feature_2', 'feature_3'], + disabledFeatures: ['feature_1', 'feature_2', 'feature_3', 'feature_4'], }; const capabilities = buildCapabilities(); diff --git a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts index 05d04295964892..00e2419136f488 100644 --- a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts +++ b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts @@ -54,22 +54,38 @@ function toggleDisabledFeatures( ) { const disabledFeatureKeys = activeSpace.disabledFeatures; - const disabledFeatures = disabledFeatureKeys - .map((key) => features.find((feature) => feature.id === key)) - .filter((feature) => typeof feature !== 'undefined') as Feature[]; + const [enabledFeatures, disabledFeatures] = features.reduce( + (acc, feature) => { + if (disabledFeatureKeys.includes(feature.id)) { + return [acc[0], [...acc[1], feature]]; + } + return [[...acc[0], feature], acc[1]]; + }, + [[], []] as [Feature[], Feature[]] + ); const navLinks = capabilities.navLinks; const catalogueEntries = capabilities.catalogue; const managementItems = capabilities.management; + const enabledAppEntries = new Set(enabledFeatures.flatMap((ef) => ef.app ?? [])); + const enabledCatalogueEntries = new Set(enabledFeatures.flatMap((ef) => ef.catalogue ?? [])); + const enabledManagementEntries = enabledFeatures.reduce((acc, feature) => { + const sections = Object.entries(feature.management ?? {}); + sections.forEach((section) => { + if (!acc.has(section[0])) { + acc.set(section[0], []); + } + acc.get(section[0])!.push(...section[1]); + }); + return acc; + }, new Map()); + for (const feature of disabledFeatures) { // Disable associated navLink, if one exists - if (feature.navLinkId && navLinks.hasOwnProperty(feature.navLinkId)) { - navLinks[feature.navLinkId] = false; - } - - feature.app.forEach((app) => { - if (navLinks.hasOwnProperty(app)) { + const featureNavLinks = feature.navLinkId ? [feature.navLinkId, ...feature.app] : feature.app; + featureNavLinks.forEach((app) => { + if (navLinks.hasOwnProperty(app) && !enabledAppEntries.has(app)) { navLinks[app] = false; } }); @@ -77,18 +93,24 @@ function toggleDisabledFeatures( // Disable associated catalogue entries const privilegeCatalogueEntries = feature.catalogue || []; privilegeCatalogueEntries.forEach((catalogueEntryId) => { - catalogueEntries[catalogueEntryId] = false; + if (!enabledCatalogueEntries.has(catalogueEntryId)) { + catalogueEntries[catalogueEntryId] = false; + } }); // Disable associated management items const privilegeManagementSections = feature.management || {}; Object.entries(privilegeManagementSections).forEach(([sectionId, sectionItems]) => { sectionItems.forEach((item) => { + const enabledManagementEntriesSection = enabledManagementEntries.get(sectionId); if ( managementItems.hasOwnProperty(sectionId) && managementItems[sectionId].hasOwnProperty(item) ) { - managementItems[sectionId][item] = false; + const isEnabledElsewhere = (enabledManagementEntriesSection ?? []).includes(item); + if (!isEnabledElsewhere) { + managementItems[sectionId][item] = false; + } } }); }); From ab96156ff8baf2629f1554468d077abeb227aa0d Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Thu, 9 Jul 2020 14:02:55 -0700 Subject: [PATCH 24/49] [Ingest Manager] Fix limited packages incorrect response (#71292) * Fix limited packages incorrect response * Add test to make sure we don't break this again :D --- .../server/services/epm/packages/get.ts | 2 +- .../apis/epm/list.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts index 78aa513d1a1dce..7093723806ea39 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts @@ -69,7 +69,7 @@ export async function getLimitedPackages(options: { }); }) ); - return installedPackagesInfo.filter((pkgInfo) => isPackageLimited).map((pkgInfo) => pkgInfo.name); + return installedPackagesInfo.filter(isPackageLimited).map((pkgInfo) => pkgInfo.name); } export async function getPackageSavedObjects(savedObjectsClient: SavedObjectsClientContract) { diff --git a/x-pack/test/ingest_manager_api_integration/apis/epm/list.ts b/x-pack/test/ingest_manager_api_integration/apis/epm/list.ts index 1ac1474e03700b..64e8aa16955a54 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/epm/list.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/epm/list.ts @@ -34,5 +34,21 @@ export default function ({ getService }: FtrProviderContext) { warnAndSkipTest(this, log); } }); + + it('lists all limited packages from the registry', async function () { + if (server.enabled) { + const fetchLimitedPackageList = async () => { + const response = await supertest + .get('/api/ingest_manager/epm/packages/limited') + .set('kbn-xsrf', 'xxx') + .expect(200); + return response.body; + }; + const listResponse = await fetchLimitedPackageList(); + expect(listResponse.response).to.eql(['endpoint']); + } else { + warnAndSkipTest(this, log); + } + }); }); } From 4de59f0759d6ff366d990f29f831e37c28641a9d Mon Sep 17 00:00:00 2001 From: Dmitry Lemeshko Date: Thu, 9 Jul 2020 23:09:48 +0200 Subject: [PATCH 25/49] [ftr] use typed chromeOptions object, adding TEST_BROWSER_BINARY_PATH (#71279) Co-authored-by: Elastic Machine --- test/functional/services/remote/webdriver.ts | 53 +++++++++++--------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/test/functional/services/remote/webdriver.ts b/test/functional/services/remote/webdriver.ts index c5613b9e27094a..867e78dfad8dcb 100644 --- a/test/functional/services/remote/webdriver.ts +++ b/test/functional/services/remote/webdriver.ts @@ -47,6 +47,7 @@ import { Browsers } from './browsers'; const throttleOption: string = process.env.TEST_THROTTLE_NETWORK as string; const headlessBrowser: string = process.env.TEST_BROWSER_HEADLESS as string; +const browserBinaryPath: string = process.env.TEST_BROWSER_BINARY_PATH as string; const remoteDebug: string = process.env.TEST_REMOTE_DEBUG as string; const certValidation: string = process.env.NODE_TLS_REJECT_UNAUTHORIZED as string; const SECOND = 1000; @@ -54,10 +55,8 @@ const MINUTE = 60 * SECOND; const NO_QUEUE_COMMANDS = ['getLog', 'getStatus', 'newSession', 'quit']; const downloadDir = resolve(REPO_ROOT, 'target/functional-tests/downloads'); const chromiumDownloadPrefs = { - prefs: { - 'download.default_directory': downloadDir, - 'download.prompt_for_download': false, - }, + 'download.default_directory': downloadDir, + 'download.prompt_for_download': false, }; /** @@ -93,8 +92,8 @@ async function attemptToCreateCommand( const buildDriverInstance = async () => { switch (browserType) { case 'chrome': { - const chromeCapabilities = Capabilities.chrome(); - const chromeOptions = [ + const chromeOptions = new chrome.Options(); + chromeOptions.addArguments( // Disables the sandbox for all process types that are normally sandboxed. 'no-sandbox', // Launches URL in new browser window. @@ -104,47 +103,55 @@ async function attemptToCreateCommand( // Use fake device for Media Stream to replace actual camera and microphone. 'use-fake-device-for-media-stream', // Bypass the media stream infobar by selecting the default device for media streams (e.g. WebRTC). Works with --use-fake-device-for-media-stream. - 'use-fake-ui-for-media-stream', - ]; + 'use-fake-ui-for-media-stream' + ); + if (process.platform === 'linux') { // The /dev/shm partition is too small in certain VM environments, causing // Chrome to fail or crash. Use this flag to work-around this issue // (a temporary directory will always be used to create anonymous shared memory files). - chromeOptions.push('disable-dev-shm-usage'); + chromeOptions.addArguments('disable-dev-shm-usage'); } + if (headlessBrowser === '1') { // Use --disable-gpu to avoid an error from a missing Mesa library, as per // See: https://chromium.googlesource.com/chromium/src/+/lkgr/headless/README.md - chromeOptions.push('headless', 'disable-gpu'); + chromeOptions.headless(); + chromeOptions.addArguments('disable-gpu'); } + if (certValidation === '0') { - chromeOptions.push('ignore-certificate-errors'); + chromeOptions.addArguments('ignore-certificate-errors'); } if (remoteDebug === '1') { // Visit chrome://inspect in chrome to remotely view/debug - chromeOptions.push('headless', 'disable-gpu', 'remote-debugging-port=9222'); + chromeOptions.headless(); + chromeOptions.addArguments('disable-gpu', 'remote-debugging-port=9222'); } - chromeCapabilities.set('goog:chromeOptions', { - w3c: true, - args: chromeOptions, - ...chromiumDownloadPrefs, - }); - chromeCapabilities.set('unexpectedAlertBehaviour', 'accept'); - chromeCapabilities.set('goog:loggingPrefs', { browser: 'ALL' }); - chromeCapabilities.setAcceptInsecureCerts(config.acceptInsecureCerts); + + if (browserBinaryPath) { + chromeOptions.setChromeBinaryPath(browserBinaryPath); + } + + const prefs = new logging.Preferences(); + prefs.setLevel(logging.Type.BROWSER, logging.Level.ALL); + chromeOptions.setUserPreferences(chromiumDownloadPrefs); + chromeOptions.setLoggingPrefs(prefs); + chromeOptions.set('unexpectedAlertBehaviour', 'accept'); + chromeOptions.setAcceptInsecureCerts(config.acceptInsecureCerts); let session; if (remoteSessionUrl) { session = await new Builder() .forBrowser(browserType) - .withCapabilities(chromeCapabilities) + .setChromeOptions(chromeOptions) .usingServer(remoteSessionUrl) .build(); } else { session = await new Builder() .forBrowser(browserType) - .withCapabilities(chromeCapabilities) + .setChromeOptions(chromeOptions) .setChromeService(new chrome.ServiceBuilder(chromeDriver.path).enableVerboseLogging()) .build(); } @@ -179,7 +186,7 @@ async function attemptToCreateCommand( edgeOptions.setBinaryPath(edgePaths.browserPath); const options = edgeOptions.get('ms:edgeOptions'); // overriding options to include preferences - Object.assign(options, chromiumDownloadPrefs); + Object.assign(options, { prefs: chromiumDownloadPrefs }); edgeOptions.set('ms:edgeOptions', options); const session = await new Builder() .forBrowser('MicrosoftEdge') From e0ab85d202f63687547bee71c48b09abd27a7acf Mon Sep 17 00:00:00 2001 From: Sonja Krause-Harder Date: Thu, 9 Jul 2020 23:16:44 +0200 Subject: [PATCH 26/49] [Ingest Manager] Add schema to usageCollector. (#71219) * Add schema to Ingest Manager usageCollector. * Use long for counters. Co-authored-by: Elastic Machine --- x-pack/.telemetryrc.json | 1 - .../server/collectors/register.ts | 28 +++++++-------- .../schema/xpack_plugins.json | 36 +++++++++++++++++++ 3 files changed, 50 insertions(+), 15 deletions(-) diff --git a/x-pack/.telemetryrc.json b/x-pack/.telemetryrc.json index 4da44667e167fe..2c16491c1096bf 100644 --- a/x-pack/.telemetryrc.json +++ b/x-pack/.telemetryrc.json @@ -7,7 +7,6 @@ "plugins/apm/server/lib/apm_telemetry/index.ts", "plugins/canvas/server/collectors/collector.ts", "plugins/infra/server/usage/usage_collector.ts", - "plugins/ingest_manager/server/collectors/register.ts", "plugins/lens/server/usage/collectors.ts", "plugins/reporting/server/usage/reporting_usage_collector.ts", "plugins/maps/server/maps_telemetry/collectors/register.ts" diff --git a/x-pack/plugins/ingest_manager/server/collectors/register.ts b/x-pack/plugins/ingest_manager/server/collectors/register.ts index aad59ee74433cb..2be8eb22bc98ce 100644 --- a/x-pack/plugins/ingest_manager/server/collectors/register.ts +++ b/x-pack/plugins/ingest_manager/server/collectors/register.ts @@ -41,20 +41,20 @@ export function registerIngestManagerUsageCollector( packages: await getPackageUsage(soClient), }; }, - // schema: { // temporarily disabled because of type errors - // fleet_enabled: { type: 'boolean' }, - // agents: { - // total: { type: 'number' }, - // online: { type: 'number' }, - // error: { type: 'number' }, - // offline: { type: 'number' }, - // }, - // packages: { - // name: { type: 'keyword' }, - // version: { type: 'keyword' }, - // enabled: { type: boolean }, - // }, - // }, + schema: { + fleet_enabled: { type: 'boolean' }, + agents: { + total: { type: 'long' }, + online: { type: 'long' }, + error: { type: 'long' }, + offline: { type: 'long' }, + }, + packages: { + name: { type: 'keyword' }, + version: { type: 'keyword' }, + enabled: { type: 'boolean' }, + }, + }, }); // register usage collector diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 1ea16a2a9940c9..fbef75b9aa9cce 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -48,6 +48,42 @@ } } }, + "ingest_manager": { + "properties": { + "fleet_enabled": { + "type": "boolean" + }, + "agents": { + "properties": { + "total": { + "type": "long" + }, + "online": { + "type": "long" + }, + "error": { + "type": "long" + }, + "offline": { + "type": "long" + } + } + }, + "packages": { + "properties": { + "name": { + "type": "keyword" + }, + "version": { + "type": "keyword" + }, + "enabled": { + "type": "boolean" + } + } + } + } + }, "mlTelemetry": { "properties": { "file_data_visualizer": { From 3153dff96b8d213858e9456282866771165b3657 Mon Sep 17 00:00:00 2001 From: spalger Date: Thu, 9 Jul 2020 14:58:11 -0700 Subject: [PATCH 27/49] fix visual regression job (#70999) --- test/scripts/jenkins_xpack_visual_regression.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/scripts/jenkins_xpack_visual_regression.sh b/test/scripts/jenkins_xpack_visual_regression.sh index 930d4a74345d90..726af43f28e31a 100755 --- a/test/scripts/jenkins_xpack_visual_regression.sh +++ b/test/scripts/jenkins_xpack_visual_regression.sh @@ -5,7 +5,7 @@ source "$KIBANA_DIR/src/dev/ci_setup/setup_percy.sh" echo " -> building and extracting default Kibana distributable" cd "$KIBANA_DIR" -node scripts/build --debug +node scripts/build --debug --no-oss linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')" installDir="$PARENT_DIR/install/kibana" mkdir -p "$installDir" From 8e2277a667883b1aedc2c4ba46ec672a154c6c90 Mon Sep 17 00:00:00 2001 From: spalger Date: Thu, 9 Jul 2020 15:04:51 -0700 Subject: [PATCH 28/49] send slack notifications on visual baseline failures --- .ci/Jenkinsfile_visual_baseline | 1 + 1 file changed, 1 insertion(+) diff --git a/.ci/Jenkinsfile_visual_baseline b/.ci/Jenkinsfile_visual_baseline index 2a16c499fa168c..7c7cc8d98c306e 100644 --- a/.ci/Jenkinsfile_visual_baseline +++ b/.ci/Jenkinsfile_visual_baseline @@ -21,5 +21,6 @@ kibanaPipeline(timeoutMinutes: 120) { } kibanaPipeline.sendMail() + slackNotifications.onFailure() } } From d570ab1eb27b1d876a8eb099bea84f234fb0199c Mon Sep 17 00:00:00 2001 From: Dmitry Lemeshko Date: Fri, 10 Jul 2020 00:50:27 +0200 Subject: [PATCH 29/49] [services/remote/webdriver] fix eslint error (#71346) --- test/functional/services/remote/webdriver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/functional/services/remote/webdriver.ts b/test/functional/services/remote/webdriver.ts index 867e78dfad8dcb..0611c80f59b92f 100644 --- a/test/functional/services/remote/webdriver.ts +++ b/test/functional/services/remote/webdriver.ts @@ -28,7 +28,7 @@ import { delay } from 'bluebird'; import chromeDriver from 'chromedriver'; // @ts-ignore types not available import geckoDriver from 'geckodriver'; -import { Builder, Capabilities, logging } from 'selenium-webdriver'; +import { Builder, logging } from 'selenium-webdriver'; import chrome from 'selenium-webdriver/chrome'; import firefox from 'selenium-webdriver/firefox'; import edge from 'selenium-webdriver/edge'; From c9e8650a216635aec08399174cfe72f991e1fb3c Mon Sep 17 00:00:00 2001 From: Alex Kahan Date: Thu, 9 Jul 2020 18:57:46 -0400 Subject: [PATCH 30/49] Deduplication of entries and items before sending to endpoint (#71297) * Deduplication of entries and items before sending to endpoint * Renaming --- .../endpoint/lib/artifacts/lists.test.ts | 139 +++++++++++++++++- .../server/endpoint/lib/artifacts/lists.ts | 19 ++- 2 files changed, 153 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts index acde455f77cb4b..1a19306b2fd606 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts @@ -139,6 +139,139 @@ describe('buildEventTypeSignal', () => { }); }); + test('it should deduplicate exception entries', async () => { + const testEntries: EntriesArray = [ + { field: 'server.domain.text', operator: 'included', type: 'match', value: 'DOMAIN' }, + { field: 'server.domain.text', operator: 'included', type: 'match', value: 'DOMAIN' }, + { field: 'server.domain.text', operator: 'included', type: 'match', value: 'DOMAIN' }, + { field: 'server.ip', operator: 'included', type: 'match', value: '192.168.1.1' }, + { + field: 'host.hostname.text', + operator: 'included', + type: 'match_any', + value: ['estc', 'kibana'], + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'server.domain', + operator: 'included', + type: 'exact_caseless', + value: 'DOMAIN', + }, + { + field: 'server.ip', + operator: 'included', + type: 'exact_cased', + value: '192.168.1.1', + }, + { + field: 'host.hostname', + operator: 'included', + type: 'exact_caseless_any', + value: ['estc', 'kibana'], + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFullEndpointExceptionList(mockExceptionClient, 'linux', 'v1'); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not deduplicate exception entries across nested boundaries', async () => { + const testEntries: EntriesArray = [ + { + entries: [ + { field: 'nested.field', operator: 'included', type: 'match', value: 'some value' }, + ], + field: 'some.parentField', + type: 'nested', + }, + // Same as above but not inside the nest + { field: 'nested.field', operator: 'included', type: 'match', value: 'some value' }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + entries: [ + { + field: 'nested.field', + operator: 'included', + type: 'exact_cased', + value: 'some value', + }, + ], + field: 'some.parentField', + type: 'nested', + }, + { + field: 'nested.field', + operator: 'included', + type: 'exact_cased', + value: 'some value', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFullEndpointExceptionList(mockExceptionClient, 'linux', 'v1'); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should deduplicate exception items', async () => { + const testEntries: EntriesArray = [ + { field: 'server.domain.text', operator: 'included', type: 'match', value: 'DOMAIN' }, + { field: 'server.ip', operator: 'included', type: 'match', value: '192.168.1.1' }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'server.domain', + operator: 'included', + type: 'exact_caseless', + value: 'DOMAIN', + }, + { + field: 'server.ip', + operator: 'included', + type: 'exact_cased', + value: '192.168.1.1', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + + // Create a second exception item with the same entries + first.data[1] = getExceptionListItemSchemaMock(); + first.data[1].entries = testEntries; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFullEndpointExceptionList(mockExceptionClient, 'linux', 'v1'); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + test('it should ignore unsupported entries', async () => { // Lists and exists are not supported by the Endpoint const testEntries: EntriesArray = [ @@ -178,8 +311,9 @@ describe('buildEventTypeSignal', () => { }); test('it should convert the exception lists response to the proper endpoint format while paging', async () => { - // The first call returns one exception + // The first call returns two exceptions const first = getFoundExceptionListItemSchemaMock(); + first.data.push(getExceptionListItemSchemaMock()); // The second call returns two exceptions const second = getFoundExceptionListItemSchemaMock(); @@ -194,7 +328,8 @@ describe('buildEventTypeSignal', () => { .mockReturnValueOnce(second) .mockReturnValueOnce(third); const resp = await getFullEndpointExceptionList(mockExceptionClient, 'linux', 'v1'); - expect(resp.entries.length).toEqual(3); + // Expect 2 exceptions, the first two calls returned the same exception list items + expect(resp.entries.length).toEqual(2); }); test('it should handle no exceptions', async () => { diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts index 556405adff62f9..b756c4e3d14c33 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts @@ -97,10 +97,18 @@ export function translateToEndpointExceptions( exc: FoundExceptionListItemSchema, schemaVersion: string ): TranslatedExceptionListItem[] { + const entrySet = new Set(); + const entriesFiltered: TranslatedExceptionListItem[] = []; if (schemaVersion === 'v1') { - return exc.data.map((item) => { - return translateItem(schemaVersion, item); + exc.data.forEach((entry) => { + const translatedItem = translateItem(schemaVersion, entry); + const entryHash = createHash('sha256').update(JSON.stringify(translatedItem)).digest('hex'); + if (!entrySet.has(entryHash)) { + entriesFiltered.push(translatedItem); + entrySet.add(entryHash); + } }); + return entriesFiltered; } else { throw new Error('unsupported schemaVersion'); } @@ -124,12 +132,17 @@ function translateItem( schemaVersion: string, item: ExceptionListItemSchema ): TranslatedExceptionListItem { + const itemSet = new Set(); return { type: item.type, entries: item.entries.reduce((translatedEntries: TranslatedEntry[], entry) => { const translatedEntry = translateEntry(schemaVersion, entry); if (translatedEntry !== undefined && translatedEntryType.is(translatedEntry)) { - translatedEntries.push(translatedEntry); + const itemHash = createHash('sha256').update(JSON.stringify(translatedEntry)).digest('hex'); + if (!itemSet.has(itemHash)) { + translatedEntries.push(translatedEntry); + itemSet.add(itemHash); + } } return translatedEntries; }, []), From 589a891bb3829c1b8b00ead2f1ce18ee74916341 Mon Sep 17 00:00:00 2001 From: spalger Date: Thu, 9 Jul 2020 15:58:39 -0700 Subject: [PATCH 31/49] jenkins_xpack_saved_objects_field_metrics.sh expects to be run from the KIBANA_DIR in CI --- test/scripts/jenkins_xpack_visual_regression.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/scripts/jenkins_xpack_visual_regression.sh b/test/scripts/jenkins_xpack_visual_regression.sh index 726af43f28e31a..ac567a188a6d40 100755 --- a/test/scripts/jenkins_xpack_visual_regression.sh +++ b/test/scripts/jenkins_xpack_visual_regression.sh @@ -22,5 +22,5 @@ yarn percy exec -t 10000 -- -- \ # cd "$KIBANA_DIR" # source "test/scripts/jenkins_xpack_page_load_metrics.sh" -cd "$XPACK_DIR" -source "$KIBANA_DIR/test/scripts/jenkins_xpack_saved_objects_field_metrics.sh" +cd "$KIBANA_DIR" +source "test/scripts/jenkins_xpack_saved_objects_field_metrics.sh" From 1a81eb5c19d5e622b4f0ba962153d0812650f6d2 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Fri, 10 Jul 2020 02:23:20 +0300 Subject: [PATCH 32/49] [SIEM][CASE] IBM Resilient Connector (#66385) --- x-pack/plugins/actions/README.md | 79 ++- .../builtin_action_types/case/utils.test.ts | 2 +- .../server/builtin_action_types/index.ts | 2 + .../resilient/api.test.ts | 517 +++++++++++++++++ .../builtin_action_types/resilient/api.ts | 7 + .../builtin_action_types/resilient/config.ts | 14 + .../builtin_action_types/resilient/index.ts | 24 + .../builtin_action_types/resilient/mocks.ts | 124 ++++ .../builtin_action_types/resilient/schema.ts | 22 + .../resilient/service.test.ts | 422 ++++++++++++++ .../builtin_action_types/resilient/service.ts | 197 +++++++ .../resilient/translations.ts | 11 + .../builtin_action_types/resilient/types.ts | 46 ++ .../resilient/validators.ts | 13 + .../case/common/api/cases/configure.ts | 8 +- .../case/common/api/connectors/index.ts | 1 + .../case/common/api/connectors/resilient.ts | 15 + x-pack/plugins/case/common/constants.ts | 2 +- .../components/configure_cases/index.test.tsx | 3 + .../public/common/lib/connectors/config.ts | 2 + .../public/common/lib/connectors/index.ts | 1 + .../common/lib/connectors/resilient/config.ts | 40 ++ .../lib/connectors/resilient/flyout.tsx | 114 ++++ .../common/lib/connectors/resilient/index.tsx | 54 ++ .../common/lib/connectors/resilient/logo.svg | 3 + .../lib/connectors/resilient/translations.ts | 72 +++ .../common/lib/connectors/resilient/types.ts | 22 + .../security_solution/public/plugin.tsx | 3 +- .../alerting_api_integration/common/config.ts | 1 + .../actions_simulators/server/plugin.ts | 4 + .../server/resilient_simulation.ts | 111 ++++ .../actions/builtin_action_types/resilient.ts | 549 ++++++++++++++++++ .../tests/actions/index.ts | 1 + 33 files changed, 2465 insertions(+), 21 deletions(-) create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/api.test.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/api.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/config.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/mocks.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/translations.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/validators.ts create mode 100644 x-pack/plugins/case/common/api/connectors/resilient.ts create mode 100644 x-pack/plugins/security_solution/public/common/lib/connectors/resilient/config.ts create mode 100644 x-pack/plugins/security_solution/public/common/lib/connectors/resilient/flyout.tsx create mode 100644 x-pack/plugins/security_solution/public/common/lib/connectors/resilient/index.tsx create mode 100644 x-pack/plugins/security_solution/public/common/lib/connectors/resilient/logo.svg create mode 100644 x-pack/plugins/security_solution/public/common/lib/connectors/resilient/translations.ts create mode 100644 x-pack/plugins/security_solution/public/common/lib/connectors/resilient/types.ts create mode 100644 x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/resilient_simulation.ts create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index 494f2f38e8bffc..9e07727204f88f 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -26,15 +26,19 @@ Table of Contents - [Executor](#executor) - [Example](#example) - [RESTful API](#restful-api) - - [`POST /api/actions/action`: Create action](#post-apiaction-create-action) - - [`DELETE /api/actions/action/{id}`: Delete action](#delete-apiactionid-delete-action) - - [`GET /api/actions`: Get all actions](#get-apiactiongetall-get-all-actions) - - [`GET /api/actions/action/{id}`: Get action](#get-apiactionid-get-action) - - [`GET /api/actions/list_action_types`: List action types](#get-apiactiontypes-list-action-types) - - [`PUT /api/actions/action/{id}`: Update action](#put-apiactionid-update-action) - - [`POST /api/actions/action/{id}/_execute`: Execute action](#post-apiactionidexecute-execute-action) + - [`POST /api/actions/action`: Create action](#post-apiactionsaction-create-action) + - [`DELETE /api/actions/action/{id}`: Delete action](#delete-apiactionsactionid-delete-action) + - [`GET /api/actions`: Get all actions](#get-apiactions-get-all-actions) + - [`GET /api/actions/action/{id}`: Get action](#get-apiactionsactionid-get-action) + - [`GET /api/actions/list_action_types`: List action types](#get-apiactionslist_action_types-list-action-types) + - [`PUT /api/actions/action/{id}`: Update action](#put-apiactionsactionid-update-action) + - [`POST /api/actions/action/{id}/_execute`: Execute action](#post-apiactionsactionid_execute-execute-action) - [Firing actions](#firing-actions) + - [Accessing a scoped ActionsClient](#accessing-a-scoped-actionsclient) + - [actionsClient.enqueueExecution(options)](#actionsclientenqueueexecutionoptions) - [Example](#example-1) + - [actionsClient.execute(options)](#actionsclientexecuteoptions) + - [Example](#example-2) - [Built-in Action Types](#built-in-action-types) - [Server log](#server-log) - [`config`](#config) @@ -70,6 +74,11 @@ Table of Contents - [`secrets`](#secrets-7) - [`params`](#params-7) - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-1) + - [IBM Resilient](#ibm-resilient) + - [`config`](#config-8) + - [`secrets`](#secrets-8) + - [`params`](#params-8) + - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-2) - [Command Line Utility](#command-line-utility) - [Developing New Action Types](#developing-new-action-types) @@ -99,7 +108,7 @@ Built-In-Actions are configured using the _xpack.actions_ namespoace under _kiba | _xpack.actions._**enabled** | Feature toggle which enabled Actions in Kibana. | boolean | | _xpack.actions._**whitelistedHosts** | Which _hostnames_ are whitelisted for the Built-In-Action? This list should contain hostnames of every external service you wish to interact with using Webhooks, Email or any other built in Action. Note that you may use the string "\*" in place of a specific hostname to enable Kibana to target any URL, but keep in mind the potential use of such a feature to execute [SSRF](https://www.owasp.org/index.php/Server_Side_Request_Forgery) attacks from your server. | Array | | _xpack.actions._**enabledActionTypes** | A list of _actionTypes_ id's that are enabled. A "\*" may be used as an element to indicate all registered actionTypes should be enabled. The actionTypes registered for Kibana are `.server-log`, `.slack`, `.email`, `.index`, `.pagerduty`, `.webhook`. Default: `["*"]` | Array | -| _xpack.actions._**preconfigured** | A object of action id / preconfigured actions. Default: `{}` | Array | +| _xpack.actions._**preconfigured** | A object of action id / preconfigured actions. Default: `{}` | Array | #### Whitelisting Built-in Action Types @@ -251,6 +260,7 @@ Once you have a scoped ActionsClient you can execute an action by caling either This api schedules a task which will run the action using the current user scope at the soonest opportunity. Running the action by scheduling a task means that we will no longer have a user request by which to ascertain the action's privileges and so you might need to provide these yourself: + - The **SpaceId** in which the user's action is expected to run - When security is enabled you'll also need to provide an **apiKey** which allows us to mimic the user and their privileges. @@ -287,14 +297,14 @@ This api runs the action and asynchronously returns the result of running the ac The following table describes the properties of the `options` object. -| Property | Description | Type | -| -------- | ------------------------------------------------------------------------------------------------------ | ------ | -| id | The id of the action you want to execute. | string | -| params | The `params` value to give the action type executor. | object | +| Property | Description | Type | +| -------- | ---------------------------------------------------- | ------ | +| id | The id of the action you want to execute. | string | +| params | The `params` value to give the action type executor. | object | ## Example -As with the previous example, we'll use the action `3c5b2bd4-5424-4e4b-8cf5-c0a58c762cc5` to send an email. +As with the previous example, we'll use the action `3c5b2bd4-5424-4e4b-8cf5-c0a58c762cc5` to send an email. ```typescript const actionsClient = await server.plugins.actions.getActionsClientWithRequest(request); @@ -559,10 +569,10 @@ The Jira action uses the [V2 API](https://developer.atlassian.com/cloud/jira/pla ### `config` -| Property | Description | Type | -| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -| apiUrl | ServiceNow instance URL. | string | -| casesConfiguration | Case configuration object. The object should contain an attribute called `mapping`. A `mapping` is an array of objects. Each mapping object should be of the form `{ source: string, target: string, actionType: string }`. `source` is the Case field. `target` is the Jira field where `source` will be mapped to. `actionType` can be one of `nothing`, `overwrite` or `append`. For example the `{ source: 'title', target: 'summary', actionType: 'overwrite' }` record, inside mapping array, means that the title of a case will be mapped to the short description of an incident in ServiceNow and will be overwrite on each update. | object | +| Property | Description | Type | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| apiUrl | Jira instance URL. | string | +| casesConfiguration | Case configuration object. The object should contain an attribute called `mapping`. A `mapping` is an array of objects. Each mapping object should be of the form `{ source: string, target: string, actionType: string }`. `source` is the Case field. `target` is the Jira field where `source` will be mapped to. `actionType` can be one of `nothing`, `overwrite` or `append`. For example the `{ source: 'title', target: 'summary', actionType: 'overwrite' }` record, inside mapping array, means that the title of a case will be mapped to the short description of an incident in Jira and will be overwrite on each update. | object | ### `secrets` @@ -588,6 +598,41 @@ The Jira action uses the [V2 API](https://developer.atlassian.com/cloud/jira/pla | comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }` | object[] _(optional)_ | | externalId | The id of the incident in Jira. If presented the incident will be update. Otherwise a new incident will be created. | string _(optional)_ | +## IBM Resilient + +ID: `.resilient` + +### `config` + +| Property | Description | Type | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | +| apiUrl | IBM Resilient instance URL. | string | +| casesConfiguration | Case configuration object. The object should contain an attribute called `mapping`. A `mapping` is an array of objects. Each mapping object should be of the form `{ source: string, target: string, actionType: string }`. `source` is the Case field. `target` is the Jira field where `source` will be mapped to. `actionType` can be one of `nothing`, `overwrite` or `append`. For example the `{ source: 'title', target: 'summary', actionType: 'overwrite' }` record, inside mapping array, means that the title of a case will be mapped to the short description of an incident in IBM Resilient and will be overwrite on each update. | object | + +### `secrets` + +| Property | Description | Type | +| ------------ | -------------------------------------------- | ------ | +| apiKeyId | API key ID for HTTP Basic authentication | string | +| apiKeySecret | API key secret for HTTP Basic authentication | string | + +### `params` + +| Property | Description | Type | +| --------------- | ------------------------------------------------------------------------------------ | ------ | +| subAction | The sub action to perform. It can be `pushToService`, `handshake`, and `getIncident` | string | +| subActionParams | The parameters of the sub action | object | + +#### `subActionParams (pushToService)` + +| Property | Description | Type | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| caseId | The case id | string | +| title | The title of the case | string _(optional)_ | +| description | The description of the case | string _(optional)_ | +| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }` | object[] _(optional)_ | +| externalId | The id of the incident in IBM Resilient. If presented the incident will be update. Otherwise a new incident will be created. | string _(optional)_ | + # Command Line Utility The [`kbn-action`](https://github.com/pmuellr/kbn-action) tool can be used to send HTTP requests to the Actions plugin. For instance, to create a Slack action from the `.slack` Action Type, use the following command: diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts index dbb18fa5c695c4..2e3cee3946d618 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts @@ -243,7 +243,7 @@ describe('transformFields', () => { }); }); - test('add newline character to descripton', () => { + test('add newline character to description', () => { const fields = prepareFieldsForTransformation({ externalCase: fullParams.externalCase, mapping: finalMapping, diff --git a/x-pack/plugins/actions/server/builtin_action_types/index.ts b/x-pack/plugins/actions/server/builtin_action_types/index.ts index 0020161789d716..80a171cbe624d2 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/index.ts @@ -16,6 +16,7 @@ import { getActionType as getSlackActionType } from './slack'; import { getActionType as getWebhookActionType } from './webhook'; import { getActionType as getServiceNowActionType } from './servicenow'; import { getActionType as getJiraActionType } from './jira'; +import { getActionType as getResilientActionType } from './resilient'; export function registerBuiltInActionTypes({ actionsConfigUtils: configurationUtilities, @@ -34,4 +35,5 @@ export function registerBuiltInActionTypes({ actionTypeRegistry.register(getWebhookActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getServiceNowActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getJiraActionType({ configurationUtilities })); + actionTypeRegistry.register(getResilientActionType({ configurationUtilities })); } diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/api.test.ts new file mode 100644 index 00000000000000..734f6be382629d --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/api.test.ts @@ -0,0 +1,517 @@ +/* + * 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 { api } from '../case/api'; +import { externalServiceMock, mapping, apiParams } from './mocks'; +import { ExternalService } from '../case/types'; + +describe('api', () => { + let externalService: jest.Mocked; + + beforeEach(() => { + externalService = externalServiceMock.create(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('pushToService', () => { + describe('create incident', () => { + test('it creates an incident', async () => { + const params = { ...apiParams, externalId: null }; + const res = await api.pushToService({ externalService, mapping, params }); + + expect(res).toEqual({ + id: '1', + title: '1', + pushedDate: '2020-06-03T15:09:13.606Z', + url: 'https://resilient.elastic.co/#incidents/1', + comments: [ + { + commentId: 'case-comment-1', + pushedDate: '2020-06-03T15:09:13.606Z', + }, + { + commentId: 'case-comment-2', + pushedDate: '2020-06-03T15:09:13.606Z', + }, + ], + }); + }); + + test('it creates an incident without comments', async () => { + const params = { ...apiParams, externalId: null, comments: [] }; + const res = await api.pushToService({ externalService, mapping, params }); + + expect(res).toEqual({ + id: '1', + title: '1', + pushedDate: '2020-06-03T15:09:13.606Z', + url: 'https://resilient.elastic.co/#incidents/1', + }); + }); + + test('it calls createIncident correctly', async () => { + const params = { ...apiParams, externalId: null }; + await api.pushToService({ externalService, mapping, params }); + + expect(externalService.createIncident).toHaveBeenCalledWith({ + incident: { + description: + 'Incident description (created at 2020-06-03T15:09:13.606Z by Elastic User)', + name: 'Incident title (created at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + expect(externalService.updateIncident).not.toHaveBeenCalled(); + }); + + test('it calls createComment correctly', async () => { + const params = { ...apiParams, externalId: null }; + await api.pushToService({ externalService, mapping, params }); + expect(externalService.createComment).toHaveBeenCalledTimes(2); + expect(externalService.createComment).toHaveBeenNthCalledWith(1, { + incidentId: '1', + comment: { + commentId: 'case-comment-1', + comment: 'A comment (added at 2020-06-03T15:09:13.606Z by Elastic User)', + createdAt: '2020-06-03T15:09:13.606Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-06-03T15:09:13.606Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, + field: 'comments', + }); + + expect(externalService.createComment).toHaveBeenNthCalledWith(2, { + incidentId: '1', + comment: { + commentId: 'case-comment-2', + comment: 'Another comment (added at 2020-06-03T15:09:13.606Z by Elastic User)', + createdAt: '2020-06-03T15:09:13.606Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-06-03T15:09:13.606Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, + field: 'comments', + }); + }); + }); + + describe('update incident', () => { + test('it updates an incident', async () => { + const res = await api.pushToService({ externalService, mapping, params: apiParams }); + + expect(res).toEqual({ + id: '1', + title: '1', + pushedDate: '2020-06-03T15:09:13.606Z', + url: 'https://resilient.elastic.co/#incidents/1', + comments: [ + { + commentId: 'case-comment-1', + pushedDate: '2020-06-03T15:09:13.606Z', + }, + { + commentId: 'case-comment-2', + pushedDate: '2020-06-03T15:09:13.606Z', + }, + ], + }); + }); + + test('it updates an incident without comments', async () => { + const params = { ...apiParams, comments: [] }; + const res = await api.pushToService({ externalService, mapping, params }); + + expect(res).toEqual({ + id: '1', + title: '1', + pushedDate: '2020-06-03T15:09:13.606Z', + url: 'https://resilient.elastic.co/#incidents/1', + }); + }); + + test('it calls updateIncident correctly', async () => { + const params = { ...apiParams }; + await api.pushToService({ externalService, mapping, params }); + + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + description: + 'Incident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + expect(externalService.createIncident).not.toHaveBeenCalled(); + }); + + test('it calls createComment correctly', async () => { + const params = { ...apiParams }; + await api.pushToService({ externalService, mapping, params }); + expect(externalService.createComment).toHaveBeenCalledTimes(2); + expect(externalService.createComment).toHaveBeenNthCalledWith(1, { + incidentId: '1', + comment: { + commentId: 'case-comment-1', + comment: 'A comment (added at 2020-06-03T15:09:13.606Z by Elastic User)', + createdAt: '2020-06-03T15:09:13.606Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-06-03T15:09:13.606Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, + field: 'comments', + }); + + expect(externalService.createComment).toHaveBeenNthCalledWith(2, { + incidentId: '1', + comment: { + commentId: 'case-comment-2', + comment: 'Another comment (added at 2020-06-03T15:09:13.606Z by Elastic User)', + createdAt: '2020-06-03T15:09:13.606Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-06-03T15:09:13.606Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, + field: 'comments', + }); + }); + }); + + describe('mapping variations', () => { + test('overwrite & append', async () => { + mapping.set('title', { + target: 'name', + actionType: 'overwrite', + }); + + mapping.set('description', { + target: 'description', + actionType: 'append', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'overwrite', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + description: + 'description from ibm resilient \r\nIncident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + }); + + test('nothing & append', async () => { + mapping.set('title', { + target: 'name', + actionType: 'nothing', + }); + + mapping.set('description', { + target: 'description', + actionType: 'append', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'nothing', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + description: + 'description from ibm resilient \r\nIncident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + }); + + test('append & append', async () => { + mapping.set('title', { + target: 'name', + actionType: 'append', + }); + + mapping.set('description', { + target: 'description', + actionType: 'append', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'append', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + name: + 'title from ibm resilient \r\nIncident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + description: + 'description from ibm resilient \r\nIncident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + }); + + test('nothing & nothing', async () => { + mapping.set('title', { + target: 'name', + actionType: 'nothing', + }); + + mapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'nothing', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: {}, + }); + }); + + test('overwrite & nothing', async () => { + mapping.set('title', { + target: 'name', + actionType: 'overwrite', + }); + + mapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'overwrite', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + }); + + test('overwrite & overwrite', async () => { + mapping.set('title', { + target: 'name', + actionType: 'overwrite', + }); + + mapping.set('description', { + target: 'description', + actionType: 'overwrite', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'overwrite', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + description: + 'Incident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + }); + + test('nothing & overwrite', async () => { + mapping.set('title', { + target: 'name', + actionType: 'nothing', + }); + + mapping.set('description', { + target: 'description', + actionType: 'overwrite', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'nothing', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + description: + 'Incident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + }); + + test('append & overwrite', async () => { + mapping.set('title', { + target: 'name', + actionType: 'append', + }); + + mapping.set('description', { + target: 'description', + actionType: 'overwrite', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'append', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + name: + 'title from ibm resilient \r\nIncident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + description: + 'Incident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + }); + + test('append & nothing', async () => { + mapping.set('title', { + target: 'name', + actionType: 'append', + }); + + mapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'append', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + name: + 'title from ibm resilient \r\nIncident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + }); + + test('comment nothing', async () => { + mapping.set('title', { + target: 'name', + actionType: 'overwrite', + }); + + mapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'nothing', + }); + + mapping.set('name', { + target: 'title', + actionType: 'overwrite', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.createComment).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/api.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/api.ts new file mode 100644 index 00000000000000..3db66e5884af4a --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/api.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 { api } from '../case/api'; diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/config.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/config.ts new file mode 100644 index 00000000000000..4ce9417bfa9a16 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/config.ts @@ -0,0 +1,14 @@ +/* + * 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 { ExternalServiceConfiguration } from '../case/types'; +import * as i18n from './translations'; + +export const config: ExternalServiceConfiguration = { + id: '.resilient', + name: i18n.NAME, + minimumLicenseRequired: 'platinum', +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts new file mode 100644 index 00000000000000..e98bc71559d3f1 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts @@ -0,0 +1,24 @@ +/* + * 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 { createConnector } from '../case/utils'; + +import { api } from './api'; +import { config } from './config'; +import { validate } from './validators'; +import { createExternalService } from './service'; +import { ResilientSecretConfiguration, ResilientPublicConfiguration } from './schema'; + +export const getActionType = createConnector({ + api, + config, + validate, + createExternalService, + validationSchema: { + config: ResilientPublicConfiguration, + secrets: ResilientSecretConfiguration, + }, +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/mocks.ts new file mode 100644 index 00000000000000..bba9c58bf28c9d --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/mocks.ts @@ -0,0 +1,124 @@ +/* + * 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 { + ExternalService, + PushToServiceApiParams, + ExecutorSubActionPushParams, + MapRecord, +} from '../case/types'; + +const createMock = (): jest.Mocked => { + const service = { + getIncident: jest.fn().mockImplementation(() => + Promise.resolve({ + id: '1', + name: 'title from ibm resilient', + description: 'description from ibm resilient', + discovered_date: 1589391874472, + create_date: 1591192608323, + inc_last_modified_date: 1591192650372, + }) + ), + createIncident: jest.fn().mockImplementation(() => + Promise.resolve({ + id: '1', + title: '1', + pushedDate: '2020-06-03T15:09:13.606Z', + url: 'https://resilient.elastic.co/#incidents/1', + }) + ), + updateIncident: jest.fn().mockImplementation(() => + Promise.resolve({ + id: '1', + title: '1', + pushedDate: '2020-06-03T15:09:13.606Z', + url: 'https://resilient.elastic.co/#incidents/1', + }) + ), + createComment: jest.fn(), + }; + + service.createComment.mockImplementationOnce(() => + Promise.resolve({ + commentId: 'case-comment-1', + pushedDate: '2020-06-03T15:09:13.606Z', + externalCommentId: '1', + }) + ); + + service.createComment.mockImplementationOnce(() => + Promise.resolve({ + commentId: 'case-comment-2', + pushedDate: '2020-06-03T15:09:13.606Z', + externalCommentId: '2', + }) + ); + + return service; +}; + +const externalServiceMock = { + create: createMock, +}; + +const mapping: Map> = new Map(); + +mapping.set('title', { + target: 'name', + actionType: 'overwrite', +}); + +mapping.set('description', { + target: 'description', + actionType: 'overwrite', +}); + +mapping.set('comments', { + target: 'comments', + actionType: 'append', +}); + +mapping.set('name', { + target: 'title', + actionType: 'overwrite', +}); + +const executorParams: ExecutorSubActionPushParams = { + savedObjectId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', + externalId: 'incident-3', + createdAt: '2020-06-03T15:09:13.606Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: '2020-06-03T15:09:13.606Z', + updatedBy: { fullName: 'Elastic User', username: 'elastic' }, + title: 'Incident title', + description: 'Incident description', + comments: [ + { + commentId: 'case-comment-1', + comment: 'A comment', + createdAt: '2020-06-03T15:09:13.606Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: '2020-06-03T15:09:13.606Z', + updatedBy: { fullName: 'Elastic User', username: 'elastic' }, + }, + { + commentId: 'case-comment-2', + comment: 'Another comment', + createdAt: '2020-06-03T15:09:13.606Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: '2020-06-03T15:09:13.606Z', + updatedBy: { fullName: 'Elastic User', username: 'elastic' }, + }, + ], +}; + +const apiParams: PushToServiceApiParams = { + ...executorParams, + externalCase: { name: 'Incident title', description: 'Incident description' }, +}; + +export { externalServiceMock, mapping, executorParams, apiParams }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts new file mode 100644 index 00000000000000..c13de2b27e2b9e --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts @@ -0,0 +1,22 @@ +/* + * 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 { ExternalIncidentServiceConfiguration } from '../case/schema'; + +export const ResilientPublicConfiguration = { + orgId: schema.string(), + ...ExternalIncidentServiceConfiguration, +}; + +export const ResilientPublicConfigurationSchema = schema.object(ResilientPublicConfiguration); + +export const ResilientSecretConfiguration = { + apiKeyId: schema.string(), + apiKeySecret: schema.string(), +}; + +export const ResilientSecretConfigurationSchema = schema.object(ResilientSecretConfiguration); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts new file mode 100644 index 00000000000000..573885698014e5 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts @@ -0,0 +1,422 @@ +/* + * 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 axios from 'axios'; + +import { createExternalService, getValueTextContent, formatUpdateRequest } from './service'; +import * as utils from '../lib/axios_utils'; +import { ExternalService } from '../case/types'; + +jest.mock('axios'); +jest.mock('../lib/axios_utils', () => { + const originalUtils = jest.requireActual('../lib/axios_utils'); + return { + ...originalUtils, + request: jest.fn(), + }; +}); + +axios.create = jest.fn(() => axios); +const requestMock = utils.request as jest.Mock; +const now = Date.now; +const TIMESTAMP = 1589391874472; + +// Incident update makes three calls to the API. +// The function below mocks this calls. +// a) Get the latest incident +// b) Update the incident +// c) Get the updated incident +const mockIncidentUpdate = (withUpdateError = false) => { + requestMock.mockImplementationOnce(() => ({ + data: { + id: '1', + name: 'title', + description: { + format: 'html', + content: 'description', + }, + }, + })); + + if (withUpdateError) { + requestMock.mockImplementationOnce(() => { + throw new Error('An error has occurred'); + }); + } else { + requestMock.mockImplementationOnce(() => ({ + data: { + success: true, + id: '1', + inc_last_modified_date: 1589391874472, + }, + })); + } + + requestMock.mockImplementationOnce(() => ({ + data: { + id: '1', + name: 'title_updated', + description: { + format: 'html', + content: 'desc_updated', + }, + inc_last_modified_date: 1589391874472, + }, + })); +}; + +describe('IBM Resilient service', () => { + let service: ExternalService; + + beforeAll(() => { + service = createExternalService({ + config: { apiUrl: 'https://resilient.elastic.co', orgId: '201' }, + secrets: { apiKeyId: 'keyId', apiKeySecret: 'secret' }, + }); + }); + + afterAll(() => { + Date.now = now; + }); + + beforeEach(() => { + jest.resetAllMocks(); + Date.now = jest.fn().mockReturnValue(TIMESTAMP); + }); + + describe('getValueTextContent', () => { + test('transforms correctly', () => { + expect(getValueTextContent('name', 'title')).toEqual({ + text: 'title', + }); + }); + + test('transforms correctly the description', () => { + expect(getValueTextContent('description', 'desc')).toEqual({ + textarea: { + format: 'html', + content: 'desc', + }, + }); + }); + }); + + describe('formatUpdateRequest', () => { + test('transforms correctly', () => { + const oldIncident = { name: 'title', description: 'desc' }; + const newIncident = { name: 'title_updated', description: 'desc_updated' }; + expect(formatUpdateRequest({ oldIncident, newIncident })).toEqual({ + changes: [ + { + field: { name: 'name' }, + old_value: { text: 'title' }, + new_value: { text: 'title_updated' }, + }, + { + field: { name: 'description' }, + old_value: { + textarea: { + format: 'html', + content: 'desc', + }, + }, + new_value: { + textarea: { + format: 'html', + content: 'desc_updated', + }, + }, + }, + ], + }); + }); + }); + + describe('createExternalService', () => { + test('throws without url', () => { + expect(() => + createExternalService({ + config: { apiUrl: null, orgId: '201' }, + secrets: { apiKeyId: 'token', apiKeySecret: 'secret' }, + }) + ).toThrow(); + }); + + test('throws without orgId', () => { + expect(() => + createExternalService({ + config: { apiUrl: 'test.com', orgId: null }, + secrets: { apiKeyId: 'token', apiKeySecret: 'secret' }, + }) + ).toThrow(); + }); + + test('throws without username', () => { + expect(() => + createExternalService({ + config: { apiUrl: 'test.com', orgId: '201' }, + secrets: { apiKeyId: '', apiKeySecret: 'secret' }, + }) + ).toThrow(); + }); + + test('throws without password', () => { + expect(() => + createExternalService({ + config: { apiUrl: 'test.com', orgId: '201' }, + secrets: { apiKeyId: '', apiKeySecret: undefined }, + }) + ).toThrow(); + }); + }); + + describe('getIncident', () => { + test('it returns the incident correctly', async () => { + requestMock.mockImplementation(() => ({ + data: { + id: '1', + name: '1', + description: { + format: 'html', + content: 'description', + }, + }, + })); + const res = await service.getIncident('1'); + expect(res).toEqual({ id: '1', name: '1', description: 'description' }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { id: '1' }, + })); + + await service.getIncident('1'); + expect(requestMock).toHaveBeenCalledWith({ + axios, + url: 'https://resilient.elastic.co/rest/orgs/201/incidents/1', + params: { + text_content_output_format: 'objects_convert', + }, + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + expect(service.getIncident('1')).rejects.toThrow( + 'Unable to get incident with id 1. Error: An error has occurred' + ); + }); + }); + + describe('createIncident', () => { + test('it creates the incident correctly', async () => { + requestMock.mockImplementation(() => ({ + data: { + id: '1', + name: 'title', + description: 'description', + discovered_date: 1589391874472, + create_date: 1589391874472, + }, + })); + + const res = await service.createIncident({ + incident: { name: 'title', description: 'desc' }, + }); + + expect(res).toEqual({ + title: '1', + id: '1', + pushedDate: '2020-05-13T17:44:34.472Z', + url: 'https://resilient.elastic.co/#incidents/1', + }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { + id: '1', + name: 'title', + description: 'description', + discovered_date: 1589391874472, + create_date: 1589391874472, + }, + })); + + await service.createIncident({ + incident: { name: 'title', description: 'desc' }, + }); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + url: 'https://resilient.elastic.co/rest/orgs/201/incidents', + method: 'post', + data: { + name: 'title', + description: { + format: 'html', + content: 'desc', + }, + discovered_date: TIMESTAMP, + }, + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + expect( + service.createIncident({ + incident: { name: 'title', description: 'desc' }, + }) + ).rejects.toThrow( + '[Action][IBM Resilient]: Unable to create incident. Error: An error has occurred' + ); + }); + }); + + describe('updateIncident', () => { + test('it updates the incident correctly', async () => { + mockIncidentUpdate(); + const res = await service.updateIncident({ + incidentId: '1', + incident: { name: 'title_updated', description: 'desc_updated' }, + }); + + expect(res).toEqual({ + title: '1', + id: '1', + pushedDate: '2020-05-13T17:44:34.472Z', + url: 'https://resilient.elastic.co/#incidents/1', + }); + }); + + test('it should call request with correct arguments', async () => { + mockIncidentUpdate(); + + await service.updateIncident({ + incidentId: '1', + incident: { name: 'title_updated', description: 'desc_updated' }, + }); + + // Incident update makes three calls to the API. + // The second call to the API is the update call. + expect(requestMock.mock.calls[1][0]).toEqual({ + axios, + method: 'patch', + url: 'https://resilient.elastic.co/rest/orgs/201/incidents/1', + data: { + changes: [ + { + field: { name: 'name' }, + old_value: { text: 'title' }, + new_value: { text: 'title_updated' }, + }, + { + field: { name: 'description' }, + old_value: { + textarea: { + content: 'description', + format: 'html', + }, + }, + new_value: { + textarea: { + content: 'desc_updated', + format: 'html', + }, + }, + }, + ], + }, + }); + }); + + test('it should throw an error', async () => { + mockIncidentUpdate(true); + + expect( + service.updateIncident({ + incidentId: '1', + incident: { name: 'title', description: 'desc' }, + }) + ).rejects.toThrow( + '[Action][IBM Resilient]: Unable to update incident with id 1. Error: An error has occurred' + ); + }); + }); + + describe('createComment', () => { + test('it creates the comment correctly', async () => { + requestMock.mockImplementation(() => ({ + data: { + id: '1', + create_date: 1589391874472, + }, + })); + + const res = await service.createComment({ + incidentId: '1', + comment: { comment: 'comment', commentId: 'comment-1' }, + field: 'comments', + }); + + expect(res).toEqual({ + commentId: 'comment-1', + pushedDate: '2020-05-13T17:44:34.472Z', + externalCommentId: '1', + }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { + id: '1', + create_date: 1589391874472, + }, + })); + + await service.createComment({ + incidentId: '1', + comment: { comment: 'comment', commentId: 'comment-1' }, + field: 'my_field', + }); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + method: 'post', + url: 'https://resilient.elastic.co/rest/orgs/201/incidents/1/comments', + data: { + text: { + content: 'comment', + format: 'text', + }, + }, + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + expect( + service.createComment({ + incidentId: '1', + comment: { comment: 'comment', commentId: 'comment-1' }, + field: 'comments', + }) + ).rejects.toThrow( + '[Action][IBM Resilient]: Unable to create comment at incident with id 1. Error: An error has occurred' + ); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts new file mode 100644 index 00000000000000..8d0526ca3b5718 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts @@ -0,0 +1,197 @@ +/* + * 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 axios from 'axios'; + +import { ExternalServiceCredentials, ExternalService, ExternalServiceParams } from '../case/types'; +import { + ResilientPublicConfigurationType, + ResilientSecretConfigurationType, + CreateIncidentRequest, + UpdateIncidentRequest, + CreateCommentRequest, + UpdateFieldText, + UpdateFieldTextArea, +} from './types'; + +import * as i18n from './translations'; +import { getErrorMessage, request } from '../lib/axios_utils'; + +const BASE_URL = `rest`; +const INCIDENT_URL = `incidents`; +const COMMENT_URL = `comments`; + +const VIEW_INCIDENT_URL = `#incidents`; + +export const getValueTextContent = ( + field: string, + value: string +): UpdateFieldText | UpdateFieldTextArea => { + if (field === 'description') { + return { + textarea: { + format: 'html', + content: value, + }, + }; + } + + return { + text: value, + }; +}; + +export const formatUpdateRequest = ({ + oldIncident, + newIncident, +}: ExternalServiceParams): UpdateIncidentRequest => { + return { + changes: Object.keys(newIncident).map((key) => ({ + field: { name: key }, + old_value: getValueTextContent(key, oldIncident[key]), + new_value: getValueTextContent(key, newIncident[key]), + })), + }; +}; + +export const createExternalService = ({ + config, + secrets, +}: ExternalServiceCredentials): ExternalService => { + const { apiUrl: url, orgId } = config as ResilientPublicConfigurationType; + const { apiKeyId, apiKeySecret } = secrets as ResilientSecretConfigurationType; + + if (!url || !orgId || !apiKeyId || !apiKeySecret) { + throw Error(`[Action]${i18n.NAME}: Wrong configuration.`); + } + + const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url; + const incidentUrl = `${urlWithoutTrailingSlash}/${BASE_URL}/orgs/${orgId}/${INCIDENT_URL}`; + const commentUrl = `${incidentUrl}/{inc_id}/${COMMENT_URL}`; + const axiosInstance = axios.create({ + auth: { username: apiKeyId, password: apiKeySecret }, + }); + + const getIncidentViewURL = (key: string) => { + return `${urlWithoutTrailingSlash}/${VIEW_INCIDENT_URL}/${key}`; + }; + + const getCommentsURL = (incidentId: string) => { + return commentUrl.replace('{inc_id}', incidentId); + }; + + const getIncident = async (id: string) => { + try { + const res = await request({ + axios: axiosInstance, + url: `${incidentUrl}/${id}`, + params: { + text_content_output_format: 'objects_convert', + }, + }); + + return { ...res.data, description: res.data.description?.content ?? '' }; + } catch (error) { + throw new Error( + getErrorMessage(i18n.NAME, `Unable to get incident with id ${id}. Error: ${error.message}`) + ); + } + }; + + const createIncident = async ({ incident }: ExternalServiceParams) => { + try { + const res = await request({ + axios: axiosInstance, + url: `${incidentUrl}`, + method: 'post', + data: { + ...incident, + description: { + format: 'html', + content: incident.description ?? '', + }, + discovered_date: Date.now(), + }, + }); + + return { + title: `${res.data.id}`, + id: `${res.data.id}`, + pushedDate: new Date(res.data.create_date).toISOString(), + url: getIncidentViewURL(res.data.id), + }; + } catch (error) { + throw new Error( + getErrorMessage(i18n.NAME, `Unable to create incident. Error: ${error.message}`) + ); + } + }; + + const updateIncident = async ({ incidentId, incident }: ExternalServiceParams) => { + try { + const latestIncident = await getIncident(incidentId); + + const data = formatUpdateRequest({ oldIncident: latestIncident, newIncident: incident }); + const res = await request({ + axios: axiosInstance, + method: 'patch', + url: `${incidentUrl}/${incidentId}`, + data, + }); + + if (!res.data.success) { + throw new Error(res.data.message); + } + + const updatedIncident = await getIncident(incidentId); + + return { + title: `${updatedIncident.id}`, + id: `${updatedIncident.id}`, + pushedDate: new Date(updatedIncident.inc_last_modified_date).toISOString(), + url: getIncidentViewURL(updatedIncident.id), + }; + } catch (error) { + throw new Error( + getErrorMessage( + i18n.NAME, + `Unable to update incident with id ${incidentId}. Error: ${error.message}` + ) + ); + } + }; + + const createComment = async ({ incidentId, comment, field }: ExternalServiceParams) => { + try { + const res = await request({ + axios: axiosInstance, + method: 'post', + url: getCommentsURL(incidentId), + data: { text: { format: 'text', content: comment.comment } }, + }); + + return { + commentId: comment.commentId, + externalCommentId: res.data.id, + pushedDate: new Date(res.data.create_date).toISOString(), + }; + } catch (error) { + throw new Error( + getErrorMessage( + i18n.NAME, + `Unable to create comment at incident with id ${incidentId}. Error: ${error.message}` + ) + ); + } + }; + + return { + getIncident, + createIncident, + updateIncident, + createComment, + }; +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/translations.ts new file mode 100644 index 00000000000000..d952838d5a2b34 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/translations.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const NAME = i18n.translate('xpack.actions.builtin.case.resilientTitle', { + defaultMessage: 'IBM Resilient', +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts new file mode 100644 index 00000000000000..6869e2ff3a1056 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts @@ -0,0 +1,46 @@ +/* + * 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 { TypeOf } from '@kbn/config-schema'; +import { ResilientPublicConfigurationSchema, ResilientSecretConfigurationSchema } from './schema'; + +export type ResilientPublicConfigurationType = TypeOf; +export type ResilientSecretConfigurationType = TypeOf; + +interface CreateIncidentBasicRequestArgs { + name: string; + description: string; + discovered_date: number; +} + +interface Comment { + text: { format: string; content: string }; +} + +interface CreateIncidentRequestArgs extends CreateIncidentBasicRequestArgs { + comments?: Comment[]; +} + +export interface UpdateFieldText { + text: string; +} + +export interface UpdateFieldTextArea { + textarea: { format: 'html' | 'text'; content: string }; +} + +interface UpdateField { + field: { name: string }; + old_value: UpdateFieldText | UpdateFieldTextArea; + new_value: UpdateFieldText | UpdateFieldTextArea; +} + +export type CreateIncidentRequest = CreateIncidentRequestArgs; +export type CreateCommentRequest = Comment; + +export interface UpdateIncidentRequest { + changes: UpdateField[]; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/validators.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/validators.ts new file mode 100644 index 00000000000000..7226071392bc63 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/validators.ts @@ -0,0 +1,13 @@ +/* + * 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 { validateCommonConfig, validateCommonSecrets } from '../case/validators'; +import { ExternalServiceValidation } from '../case/types'; + +export const validate: ExternalServiceValidation = { + config: validateCommonConfig, + secrets: validateCommonSecrets, +}; diff --git a/x-pack/plugins/case/common/api/cases/configure.ts b/x-pack/plugins/case/common/api/cases/configure.ts index 7d20011a428cfe..38fff5b190f25a 100644 --- a/x-pack/plugins/case/common/api/cases/configure.ts +++ b/x-pack/plugins/case/common/api/cases/configure.ts @@ -10,6 +10,7 @@ import { ActionResult } from '../../../../actions/common'; import { UserRT } from '../user'; import { JiraFieldsRT } from '../connectors/jira'; import { ServiceNowFieldsRT } from '../connectors/servicenow'; +import { ResilientFieldsRT } from '../connectors/resilient'; /* * This types below are related to the service now configuration @@ -29,7 +30,12 @@ const CaseFieldRT = rt.union([ rt.literal('comments'), ]); -const ThirdPartyFieldRT = rt.union([JiraFieldsRT, ServiceNowFieldsRT, rt.literal('not_mapped')]); +const ThirdPartyFieldRT = rt.union([ + JiraFieldsRT, + ServiceNowFieldsRT, + ResilientFieldsRT, + rt.literal('not_mapped'), +]); export const CasesConfigurationMapsRT = rt.type({ source: CaseFieldRT, diff --git a/x-pack/plugins/case/common/api/connectors/index.ts b/x-pack/plugins/case/common/api/connectors/index.ts index c1fc284c938b75..0a7840d3aba226 100644 --- a/x-pack/plugins/case/common/api/connectors/index.ts +++ b/x-pack/plugins/case/common/api/connectors/index.ts @@ -6,3 +6,4 @@ export * from './jira'; export * from './servicenow'; +export * from './resilient'; diff --git a/x-pack/plugins/case/common/api/connectors/resilient.ts b/x-pack/plugins/case/common/api/connectors/resilient.ts new file mode 100644 index 00000000000000..c7e2f198091406 --- /dev/null +++ b/x-pack/plugins/case/common/api/connectors/resilient.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +export const ResilientFieldsRT = rt.union([ + rt.literal('name'), + rt.literal('description'), + rt.literal('comments'), +]); + +export type ResilientFieldsType = rt.TypeOf; diff --git a/x-pack/plugins/case/common/constants.ts b/x-pack/plugins/case/common/constants.ts index e912c661439b2f..bd12c258a5388f 100644 --- a/x-pack/plugins/case/common/constants.ts +++ b/x-pack/plugins/case/common/constants.ts @@ -29,4 +29,4 @@ export const ACTION_URL = '/api/actions'; export const ACTION_TYPES_URL = '/api/actions/list_action_types'; export const SERVICENOW_ACTION_TYPE_ID = '.servicenow'; -export const SUPPORTED_CONNECTORS = ['.servicenow', '.jira']; +export const SUPPORTED_CONNECTORS = ['.servicenow', '.jira', '.resilient']; diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx index 91a5aa5c88beb8..7974116f4dc43e 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx @@ -166,6 +166,9 @@ describe('ConfigureCases', () => { expect.objectContaining({ id: '.jira', }), + expect.objectContaining({ + id: '.resilient', + }), ]); expect(wrapper.find(ConnectorEditFlyout).prop('editFlyoutVisible')).toBe(false); diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/config.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/config.ts index 0b19e4177f5c27..833f85712b5faa 100644 --- a/x-pack/plugins/security_solution/public/common/lib/connectors/config.ts +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/config.ts @@ -7,9 +7,11 @@ // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ServiceNowConnectorConfiguration } from '../../../../../triggers_actions_ui/public/common'; import { connector as jiraConnectorConfig } from './jira/config'; +import { connector as resilientConnectorConfig } from './resilient/config'; import { ConnectorConfiguration } from './types'; export const connectorsConfiguration: Record = { '.servicenow': ServiceNowConnectorConfiguration as ConnectorConfiguration, '.jira': jiraConnectorConfig, + '.resilient': resilientConnectorConfig, }; diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/index.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/index.ts index 83b07a2905ef0f..f32e1e0df184e7 100644 --- a/x-pack/plugins/security_solution/public/common/lib/connectors/index.ts +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/index.ts @@ -5,3 +5,4 @@ */ export { getActionType as jiraActionType } from './jira'; +export { getActionType as resilientActionType } from './resilient'; diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/config.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/config.ts new file mode 100644 index 00000000000000..7d4edbf624877a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/config.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ConnectorConfiguration } from './types'; + +import * as i18n from './translations'; +import logo from './logo.svg'; + +export const connector: ConnectorConfiguration = { + id: '.resilient', + name: i18n.RESILIENT_TITLE, + logo, + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'platinum', + fields: { + name: { + label: i18n.MAPPING_FIELD_NAME, + validSourceFields: ['title', 'description'], + defaultSourceField: 'title', + defaultActionType: 'overwrite', + }, + description: { + label: i18n.MAPPING_FIELD_DESC, + validSourceFields: ['title', 'description'], + defaultSourceField: 'description', + defaultActionType: 'overwrite', + }, + comments: { + label: i18n.MAPPING_FIELD_COMMENTS, + validSourceFields: ['comments'], + defaultSourceField: 'comments', + defaultActionType: 'append', + }, + }, +}; diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/flyout.tsx b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/flyout.tsx new file mode 100644 index 00000000000000..31bf0a4dfc34b3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/flyout.tsx @@ -0,0 +1,114 @@ +/* + * 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 { + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiFieldPassword, + EuiSpacer, +} from '@elastic/eui'; + +import * as i18n from './translations'; +import { ConnectorFlyoutFormProps } from '../types'; +import { ResilientActionConnector } from './types'; +import { withConnectorFlyout } from '../components/connector_flyout'; + +const resilientConnectorForm: React.FC> = ({ + errors, + action, + onChangeSecret, + onBlurSecret, + onChangeConfig, + onBlurConfig, +}) => { + const { orgId } = action.config; + const { apiKeyId, apiKeySecret } = action.secrets; + const isOrgIdInvalid: boolean = errors.orgId.length > 0 && orgId != null; + const isApiKeyIdInvalid: boolean = errors.apiKeyId.length > 0 && apiKeyId != null; + const isApiKeySecretInvalid: boolean = errors.apiKeySecret.length > 0 && apiKeySecret != null; + + return ( + <> + + + + onChangeConfig('orgId', evt.target.value)} + onBlur={() => onBlurConfig('orgId')} + /> + + + + + + + + onChangeSecret('apiKeyId', evt.target.value)} + onBlur={() => onBlurSecret('apiKeyId')} + /> + + + + + + + + onChangeSecret('apiKeySecret', evt.target.value)} + onBlur={() => onBlurSecret('apiKeySecret')} + /> + + + + + ); +}; + +export const resilientConnectorFlyout = withConnectorFlyout({ + ConnectorFormComponent: resilientConnectorForm, + secretKeys: ['apiKeyId', 'apiKeySecret'], + configKeys: ['orgId'], + connectorActionTypeId: '.resilient', +}); + +// eslint-disable-next-line import/no-default-export +export { resilientConnectorFlyout as default }; diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/index.tsx b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/index.tsx new file mode 100644 index 00000000000000..d3daf195582a8e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/index.tsx @@ -0,0 +1,54 @@ +/* + * 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 { lazy } from 'react'; +import { + ValidationResult, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../triggers_actions_ui/public/types'; + +import { connector } from './config'; +import { createActionType } from '../utils'; +import logo from './logo.svg'; +import { ResilientActionConnector } from './types'; +import * as i18n from './translations'; + +interface Errors { + orgId: string[]; + apiKeyId: string[]; + apiKeySecret: string[]; +} + +const validateConnector = (action: ResilientActionConnector): ValidationResult => { + const errors: Errors = { + orgId: [], + apiKeyId: [], + apiKeySecret: [], + }; + + if (!action.config.orgId) { + errors.orgId = [...errors.orgId, i18n.RESILIENT_PROJECT_KEY_LABEL]; + } + + if (!action.secrets.apiKeyId) { + errors.apiKeyId = [...errors.apiKeyId, i18n.RESILIENT_API_KEY_ID_REQUIRED]; + } + + if (!action.secrets.apiKeySecret) { + errors.apiKeySecret = [...errors.apiKeySecret, i18n.RESILIENT_API_KEY_SECRET_REQUIRED]; + } + + return { errors }; +}; + +export const getActionType = createActionType({ + id: connector.id, + iconClass: logo, + selectMessage: i18n.RESILIENT_DESC, + actionTypeTitle: connector.name, + validateConnector, + actionConnectorFields: lazy(() => import('./flyout')), +}); diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/logo.svg b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/logo.svg new file mode 100644 index 00000000000000..553c2c62b7191c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/translations.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/translations.ts new file mode 100644 index 00000000000000..f8aec2eea3d4b4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/translations.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. + */ + +import { i18n } from '@kbn/i18n'; + +export * from '../translations'; + +export const RESILIENT_DESC = i18n.translate( + 'xpack.securitySolution.case.connectors.resilient.selectMessageText', + { + defaultMessage: 'Push or update SIEM case data to a new issue in resilient', + } +); + +export const RESILIENT_TITLE = i18n.translate( + 'xpack.securitySolution.case.connectors.resilient.actionTypeTitle', + { + defaultMessage: 'IBM Resilient', + } +); + +export const RESILIENT_PROJECT_KEY_LABEL = i18n.translate( + 'xpack.securitySolution.case.connectors.resilient.orgId', + { + defaultMessage: 'Organization Id', + } +); + +export const RESILIENT_PROJECT_KEY_REQUIRED = i18n.translate( + 'xpack.securitySolution.case.connectors.resilient.requiredOrgIdTextField', + { + defaultMessage: 'Organization Id', + } +); + +export const RESILIENT_API_KEY_ID_LABEL = i18n.translate( + 'xpack.securitySolution.case.connectors.resilient.apiKeyId', + { + defaultMessage: 'API key id', + } +); + +export const RESILIENT_API_KEY_ID_REQUIRED = i18n.translate( + 'xpack.securitySolution.case.connectors.resilient.requiredApiKeyIdTextField', + { + defaultMessage: 'API key id is required', + } +); + +export const RESILIENT_API_KEY_SECRET_LABEL = i18n.translate( + 'xpack.securitySolution.case.connectors.resilient.apiKeySecret', + { + defaultMessage: 'API key secret', + } +); + +export const RESILIENT_API_KEY_SECRET_REQUIRED = i18n.translate( + 'xpack.securitySolution.case.connectors.resilient.requiredApiKeySecretTextField', + { + defaultMessage: 'API key secret is required', + } +); + +export const MAPPING_FIELD_NAME = i18n.translate( + 'xpack.securitySolution.case.configureCases.mappingFieldName', + { + defaultMessage: 'Name', + } +); diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/types.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/types.ts new file mode 100644 index 00000000000000..fe6dbb2b3674ab --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/types.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +/* eslint-disable no-restricted-imports */ +/* eslint-disable @kbn/eslint/no-restricted-paths */ + +import { + ResilientPublicConfigurationType, + ResilientSecretConfigurationType, +} from '../../../../../../actions/server/builtin_action_types/resilient/types'; + +export { ResilientFieldsType } from '../../../../../../case/common/api/connectors'; + +export * from '../types'; + +export interface ResilientActionConnector { + config: ResilientPublicConfigurationType; + secrets: ResilientSecretConfigurationType; +} diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 6096a9b0e0bb85..7bb4be6b508797 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -22,7 +22,7 @@ import { Storage } from '../../../../src/plugins/kibana_utils/public'; import { FeatureCatalogueCategory } from '../../../../src/plugins/home/public'; import { initTelemetry } from './common/lib/telemetry'; import { KibanaServices } from './common/lib/kibana/services'; -import { jiraActionType } from './common/lib/connectors'; +import { jiraActionType, resilientActionType } from './common/lib/connectors'; import { PluginSetup, PluginStart, @@ -84,6 +84,7 @@ export class Plugin implements IPlugin { const storage = new Storage(localStorage); diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 0877fdc949dc41..e3281cfdfa9a3f 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -25,6 +25,7 @@ const enabledActionTypes = [ '.server-log', '.servicenow', '.jira', + '.resilient', '.slack', '.webhook', 'test.authorization', diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts index f1ac3f91c68db6..b8b2cbdc03f39f 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts @@ -12,6 +12,7 @@ import { ActionType } from '../../../../../../../plugins/actions/server'; import { initPlugin as initPagerduty } from './pagerduty_simulation'; import { initPlugin as initServiceNow } from './servicenow_simulation'; import { initPlugin as initJira } from './jira_simulation'; +import { initPlugin as initResilient } from './resilient_simulation'; export const NAME = 'actions-FTS-external-service-simulators'; @@ -20,6 +21,7 @@ export enum ExternalServiceSimulator { SERVICENOW = 'servicenow', SLACK = 'slack', JIRA = 'jira', + RESILIENT = 'resilient', WEBHOOK = 'webhook', } @@ -33,6 +35,7 @@ export function getAllExternalServiceSimulatorPaths(): string[] { ); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/incident`); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.JIRA}/rest/api/2/issue`); + allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.RESILIENT}/rest/orgs/201/incidents`); return allPaths; } @@ -88,6 +91,7 @@ export class FixturePlugin implements Plugin, + res: KibanaResponseFactory + ): Promise> { + return jsonResponse(res, 200, { + id: '123', + create_date: 1589391874472, + }); + } + ); + + router.patch( + { + path: `${path}/rest/orgs/201/incidents/{id}`, + options: { + authRequired: false, + }, + validate: {}, + }, + async function ( + context: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise> { + return jsonResponse(res, 200, { + success: true, + }); + } + ); + + router.get( + { + path: `${path}/rest/orgs/201/incidents/{id}`, + options: { + authRequired: false, + }, + validate: {}, + }, + async function ( + context: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise> { + return jsonResponse(res, 200, { + id: '123', + create_date: 1589391874472, + inc_last_modified_date: 1589391874472, + name: 'title', + description: 'description', + }); + } + ); + + router.post( + { + path: `${path}/rest/api/2/issue/{id}/comment`, + options: { + authRequired: false, + }, + validate: {}, + }, + async function ( + context: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise> { + return jsonResponse(res, 200, { + id: '123', + created: '2020-04-27T14:17:45.490Z', + }); + } + ); +} + +function jsonResponse( + res: KibanaResponseFactory, + code: number, + object: Record = {} +) { + return res.custom>({ body: object, statusCode: code }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts new file mode 100644 index 00000000000000..a77e0414a19d48 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts @@ -0,0 +1,549 @@ +/* + * 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 expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { + getExternalServiceSimulatorPath, + ExternalServiceSimulator, +} from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; + +const mapping = [ + { + source: 'title', + target: 'name', + actionType: 'overwrite', + }, + { + source: 'description', + target: 'description', + actionType: 'overwrite', + }, + { + source: 'comments', + target: 'comments', + actionType: 'append', + }, +]; + +// eslint-disable-next-line import/no-default-export +export default function resilientTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + + const mockResilient = { + config: { + apiUrl: 'www.jiraisinkibanaactions.com', + orgId: '201', + casesConfiguration: { mapping }, + }, + secrets: { + apiKeyId: 'key', + apiKeySecret: 'secret', + }, + params: { + subAction: 'pushToService', + subActionParams: { + savedObjectId: '123', + title: 'a title', + description: 'a description', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + externalId: null, + comments: [ + { + commentId: '456', + version: 'WzU3LDFd', + comment: 'first comment', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + }, + ], + }, + }, + }; + + let resilientSimulatorURL: string = ''; + + describe('IBM Resilient', () => { + before(() => { + resilientSimulatorURL = kibanaServer.resolveUrl( + getExternalServiceSimulatorPath(ExternalServiceSimulator.RESILIENT) + ); + }); + + after(() => esArchiver.unload('empty_kibana')); + + describe('IBM Resilient - Action Creation', () => { + it('should return 200 when creating a ibm resilient action successfully', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An IBM Resilient action', + actionTypeId: '.resilient', + config: { + ...mockResilient.config, + apiUrl: resilientSimulatorURL, + }, + secrets: mockResilient.secrets, + }) + .expect(200); + + expect(createdAction).to.eql({ + id: createdAction.id, + isPreconfigured: false, + name: 'An IBM Resilient action', + actionTypeId: '.resilient', + config: { + apiUrl: resilientSimulatorURL, + orgId: mockResilient.config.orgId, + casesConfiguration: mockResilient.config.casesConfiguration, + }, + }); + + const { body: fetchedAction } = await supertest + .get(`/api/actions/action/${createdAction.id}`) + .expect(200); + + expect(fetchedAction).to.eql({ + id: fetchedAction.id, + isPreconfigured: false, + name: 'An IBM Resilient action', + actionTypeId: '.resilient', + config: { + apiUrl: resilientSimulatorURL, + orgId: mockResilient.config.orgId, + casesConfiguration: mockResilient.config.casesConfiguration, + }, + }); + }); + + it('should respond with a 400 Bad Request when creating a ibm resilient action with no apiUrl', async () => { + await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An IBM Resilient', + actionTypeId: '.resilient', + config: { orgId: '201' }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a ibm resilient action with no orgId', async () => { + await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An IBM Resilient', + actionTypeId: '.resilient', + config: { apiUrl: resilientSimulatorURL }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [orgId]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a ibm resilient action with a non whitelisted apiUrl', async () => { + await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An IBM Resilient', + actionTypeId: '.resilient', + config: { + apiUrl: 'http://resilient.mynonexistent.com', + orgId: mockResilient.config.orgId, + casesConfiguration: mockResilient.config.casesConfiguration, + }, + secrets: mockResilient.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: error configuring connector action: target url "http://resilient.mynonexistent.com" is not whitelisted in the Kibana config xpack.actions.whitelistedHosts', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a ibm resilient action without secrets', async () => { + await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An IBM Resilient', + actionTypeId: '.resilient', + config: { + apiUrl: resilientSimulatorURL, + orgId: mockResilient.config.orgId, + casesConfiguration: mockResilient.config.casesConfiguration, + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: [apiKeyId]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a ibm resilient action without casesConfiguration', async () => { + await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An IBM Resilient', + actionTypeId: '.resilient', + config: { + apiUrl: resilientSimulatorURL, + orgId: mockResilient.config.orgId, + }, + secrets: mockResilient.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [casesConfiguration.mapping]: expected value of type [array] but got [undefined]', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a ibm resilient action with empty mapping', async () => { + await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An IBM Resilient', + actionTypeId: '.resilient', + config: { + apiUrl: resilientSimulatorURL, + orgId: mockResilient.config.orgId, + casesConfiguration: { mapping: [] }, + }, + secrets: mockResilient.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [casesConfiguration.mapping]: expected non-empty but got empty', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a ibm resilient action with wrong actionType', async () => { + await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An IBM Resilient', + actionTypeId: '.resilient', + config: { + apiUrl: resilientSimulatorURL, + orgId: mockResilient.config.orgId, + casesConfiguration: { + mapping: [ + { + source: 'title', + target: 'description', + actionType: 'non-supported', + }, + ], + }, + }, + secrets: mockResilient.secrets, + }) + .expect(400); + }); + }); + + describe('IBM Resilient - Executor', () => { + let simulatedActionId: string; + before(async () => { + const { body } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A ibm resilient simulator', + actionTypeId: '.resilient', + config: { + apiUrl: resilientSimulatorURL, + orgId: mockResilient.config.orgId, + casesConfiguration: mockResilient.config.casesConfiguration, + }, + secrets: mockResilient.secrets, + }); + simulatedActionId = body.id; + }); + + describe('Validation', () => { + it('should handle failing with a simulated success without action', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: {}, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: `error validating action params: Cannot read property 'Symbol(Symbol.iterator)' of undefined`, + }); + }); + }); + + it('should handle failing with a simulated success without unsupported action', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'non-supported' }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subAction]: expected value to equal [pushToService]', + }); + }); + }); + + it('should handle failing with a simulated success without subActionParams', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'pushToService' }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should handle failing with a simulated success without savedObjectId', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'pushToService', subActionParams: {} }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should handle failing with a simulated success without title', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockResilient.params, + subActionParams: { + savedObjectId: 'success', + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.title]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should handle failing with a simulated success without createdAt', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockResilient.params, + subActionParams: { + savedObjectId: 'success', + title: 'success', + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.createdAt]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should handle failing with a simulated success without commentId', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockResilient.params, + subActionParams: { + ...mockResilient.params.subActionParams, + savedObjectId: 'success', + title: 'success', + createdAt: 'success', + createdBy: { username: 'elastic' }, + comments: [{}], + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]', + }); + }); + }); + + it('should handle failing with a simulated success without comment message', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockResilient.params, + subActionParams: { + ...mockResilient.params.subActionParams, + savedObjectId: 'success', + title: 'success', + createdAt: 'success', + createdBy: { username: 'elastic' }, + comments: [{ commentId: 'success' }], + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]', + }); + }); + }); + + it('should handle failing with a simulated success without comment.createdAt', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockResilient.params, + subActionParams: { + ...mockResilient.params.subActionParams, + savedObjectId: 'success', + title: 'success', + createdAt: 'success', + createdBy: { username: 'elastic' }, + comments: [{ commentId: 'success', comment: 'success' }], + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.createdAt]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]', + }); + }); + }); + }); + + describe('Execution', () => { + it('should handle creating an incident without comments', async () => { + const { body } = await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockResilient.params, + subActionParams: { + ...mockResilient.params.subActionParams, + comments: [], + }, + }, + }) + .expect(200); + + expect(body).to.eql({ + status: 'ok', + actionId: simulatedActionId, + data: { + id: '123', + title: '123', + pushedDate: '2020-05-13T17:44:34.472Z', + url: `${resilientSimulatorURL}/#incidents/123`, + }, + }); + }); + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts index 18b1714582d131..9cdc0c9fa663ee 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts @@ -16,6 +16,7 @@ export default function actionsTests({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./builtin_action_types/server_log')); loadTestFile(require.resolve('./builtin_action_types/servicenow')); loadTestFile(require.resolve('./builtin_action_types/jira')); + loadTestFile(require.resolve('./builtin_action_types/resilient')); loadTestFile(require.resolve('./builtin_action_types/slack')); loadTestFile(require.resolve('./builtin_action_types/webhook')); loadTestFile(require.resolve('./create')); From 87c8de8c7d481dc2a99d74c38a61d3f9b8e35c20 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Thu, 9 Jul 2020 20:37:32 -0400 Subject: [PATCH 33/49] [ML] DF Analytics: stop status polling when job stopped (#71159) * clear interval if job stopped * ensure analysisFieldsTable error messages up to date * use shared constant for job state --- .../analysis_fields_table.tsx | 39 +++++++++++++++---- .../configuration_step_form.tsx | 32 +++++++-------- .../components/create_step/progress_stats.tsx | 13 ++++--- 3 files changed, 53 insertions(+), 31 deletions(-) diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx index def6acdae14e3b..ff8797bc523c1b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx @@ -85,16 +85,27 @@ export const AnalysisFieldsTable: FC<{ includes: string[]; loadingItems: boolean; setFormState: React.Dispatch>; + minimumFieldsRequiredMessage?: string; + setMinimumFieldsRequiredMessage: React.Dispatch>; tableItems: FieldSelectionItem[]; -}> = ({ dependentVariable, includes, loadingItems, setFormState, tableItems }) => { + unsupportedFieldsError?: string; + setUnsupportedFieldsError: React.Dispatch>; +}> = ({ + dependentVariable, + includes, + loadingItems, + setFormState, + minimumFieldsRequiredMessage, + setMinimumFieldsRequiredMessage, + tableItems, + unsupportedFieldsError, + setUnsupportedFieldsError, +}) => { const [sortableProperties, setSortableProperties] = useState(); const [currentPaginationData, setCurrentPaginationData] = useState<{ pageIndex: number; itemsPerPage: number; }>({ pageIndex: 0, itemsPerPage: 5 }); - const [minimumFieldsRequiredMessage, setMinimumFieldsRequiredMessage] = useState< - undefined | string - >(undefined); useEffect(() => { if (includes.length === 0 && tableItems.length > 0) { @@ -164,8 +175,21 @@ export const AnalysisFieldsTable: FC<{ label={i18n.translate('xpack.ml.dataframe.analytics.create.includedFieldsLabel', { defaultMessage: 'Included fields', })} - isInvalid={minimumFieldsRequiredMessage !== undefined} - error={minimumFieldsRequiredMessage} + fullWidth + isInvalid={ + minimumFieldsRequiredMessage !== undefined || unsupportedFieldsError !== undefined + } + error={[ + ...(minimumFieldsRequiredMessage !== undefined ? [minimumFieldsRequiredMessage] : []), + ...(unsupportedFieldsError !== undefined + ? [ + i18n.translate('xpack.ml.dataframe.analytics.create.unsupportedFieldsError', { + defaultMessage: 'Invalid. {message}', + values: { message: unsupportedFieldsError }, + }), + ] + : []), + ]} > @@ -209,9 +233,10 @@ export const AnalysisFieldsTable: FC<{ ) { selection = [dependentVariable]; } - // If nothing selected show minimum fields required message and don't update form yet + // If includes is empty show minimum fields required message and don't update form yet if (selection.length === 0) { setMinimumFieldsRequiredMessage(minimumFieldsMessage); + setUnsupportedFieldsError(undefined); } else { setMinimumFieldsRequiredMessage(undefined); setFormState({ includes: selection }); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx index 9dae54b6537b3d..571c7731822c06 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx @@ -73,6 +73,9 @@ export const ConfigurationStepForm: FC = ({ const [includesTableItems, setIncludesTableItems] = useState([]); const [maxDistinctValuesError, setMaxDistinctValuesError] = useState(); const [unsupportedFieldsError, setUnsupportedFieldsError] = useState(); + const [minimumFieldsRequiredMessage, setMinimumFieldsRequiredMessage] = useState< + undefined | string + >(); const { setEstimatedModelMemoryLimit, setFormState } = actions; const { estimatedModelMemoryLimit, form, isJobCreated, requestMessages } = state; @@ -117,6 +120,7 @@ export const ConfigurationStepForm: FC = ({ dependentVariableEmpty || jobType === undefined || maxDistinctValuesError !== undefined || + minimumFieldsRequiredMessage !== undefined || requiredFieldsError !== undefined || unsupportedFieldsError !== undefined; @@ -400,32 +404,22 @@ export const ConfigurationStepForm: FC = ({ )} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/progress_stats.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/progress_stats.tsx index a50254334526cb..c87f0f4206feb6 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/progress_stats.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/progress_stats.tsx @@ -15,13 +15,15 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useMlKibana } from '../../../../../contexts/kibana'; -import { getDataFrameAnalyticsProgressPhase } from '../../../analytics_management/components/analytics_list/common'; +import { + getDataFrameAnalyticsProgressPhase, + DATA_FRAME_TASK_STATE, +} from '../../../analytics_management/components/analytics_list/common'; import { isGetDataFrameAnalyticsStatsResponseOk } from '../../../analytics_management/services/analytics_service/get_analytics'; import { ml } from '../../../../../services/ml_api_service'; import { DataFrameAnalyticsId } from '../../../../common/analytics'; export const PROGRESS_REFRESH_INTERVAL_MS = 1000; -const FAILED = 'failed'; export const ProgressStats: FC<{ jobId: DataFrameAnalyticsId }> = ({ jobId }) => { const [initialized, setInitialized] = useState(false); @@ -54,7 +56,7 @@ export const ProgressStats: FC<{ jobId: DataFrameAnalyticsId }> = ({ jobId }) => if (jobStats !== undefined) { const progressStats = getDataFrameAnalyticsProgressPhase(jobStats); - if (jobStats.state === FAILED) { + if (jobStats.state === DATA_FRAME_TASK_STATE.FAILED) { clearInterval(interval); setFailedJobMessage( jobStats.failure_reason || @@ -70,8 +72,9 @@ export const ProgressStats: FC<{ jobId: DataFrameAnalyticsId }> = ({ jobId }) => setCurrentProgress(progressStats); if ( - progressStats.currentPhase === progressStats.totalPhases && - progressStats.progress === 100 + (progressStats.currentPhase === progressStats.totalPhases && + progressStats.progress === 100) || + jobStats.state === DATA_FRAME_TASK_STATE.STOPPED ) { clearInterval(interval); } From c1b26651bde1740452f2497175784866080309b9 Mon Sep 17 00:00:00 2001 From: Davis Plumlee <56367316+dplumlee@users.noreply.github.com> Date: Thu, 9 Jul 2020 20:59:46 -0400 Subject: [PATCH 34/49] [Security Solution][Exceptions] - Exceptions modal pt 2 (#70886) * makes comment updates * adds tests * adds back non ecs data to timeline * comments * fixes jest tests * fixes typo --- .../exceptions/add_exception_modal/index.tsx | 32 ++- .../exceptions/edit_exception_modal/index.tsx | 12 +- .../edit_exception_modal/translations.ts | 2 +- .../components/exceptions/helpers.test.tsx | 252 +++++++++++++++++- .../common/components/exceptions/helpers.tsx | 100 ++++--- .../components/exceptions/viewer/index.tsx | 14 +- .../alerts_table/default_config.tsx | 41 ++- .../components/alerts_table/index.tsx | 20 +- .../components/manage_timeline/index.tsx | 17 +- .../body/events/event_column_view.tsx | 4 +- .../components/timeline/body/index.tsx | 14 +- 11 files changed, 429 insertions(+), 79 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index be89aa8e33718a..10d510c5f56c3f 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -43,6 +43,7 @@ import { defaultEndpointExceptionItems, entryHasListType, entryHasNonEcsType, + getMappedNonEcsValue, } from '../helpers'; import { useFetchIndexPatterns } from '../../../../detections/containers/detection_engine/rules'; @@ -65,7 +66,7 @@ interface AddExceptionModalProps { nonEcsData: TimelineNonEcsData[]; }; onCancel: () => void; - onConfirm: () => void; + onConfirm: (didCloseAlert: boolean) => void; } const Modal = styled(EuiModal)` @@ -130,8 +131,8 @@ export const AddExceptionModal = memo(function AddExceptionModal({ ); const onSuccess = useCallback(() => { displaySuccessToast(i18n.ADD_EXCEPTION_SUCCESS, dispatchToaster); - onConfirm(); - }, [dispatchToaster, onConfirm]); + onConfirm(shouldCloseAlert); + }, [dispatchToaster, onConfirm, shouldCloseAlert]); const [{ isLoading: addExceptionIsLoading }, addOrUpdateExceptionItems] = useAddOrUpdateException( { @@ -193,6 +194,12 @@ export const AddExceptionModal = memo(function AddExceptionModal({ indexPatterns, ]); + useEffect(() => { + if (shouldDisableBulkClose === true) { + setShouldBulkCloseAlert(false); + } + }, [shouldDisableBulkClose]); + const onCommentChange = useCallback( (value: string) => { setComment(value); @@ -214,6 +221,21 @@ export const AddExceptionModal = memo(function AddExceptionModal({ [setShouldBulkCloseAlert] ); + const retrieveAlertOsTypes = useCallback(() => { + const osDefaults = ['windows', 'macos', 'linux']; + if (alertData) { + const osTypes = getMappedNonEcsValue({ + data: alertData.nonEcsData, + fieldName: 'host.os.family', + }); + if (osTypes.length === 0) { + return osDefaults; + } + return osTypes; + } + return osDefaults; + }, [alertData]); + const enrichExceptionItems = useCallback(() => { let enriched: Array = []; enriched = @@ -221,11 +243,11 @@ export const AddExceptionModal = memo(function AddExceptionModal({ ? enrichExceptionItemsWithComments(exceptionItemsToAdd, [{ comment }]) : exceptionItemsToAdd; if (exceptionListType === 'endpoint') { - const osTypes = alertData ? ['windows'] : ['windows', 'macos', 'linux']; + const osTypes = retrieveAlertOsTypes(); enriched = enrichExceptionItemsWithOS(enriched, osTypes); } return enriched; - }, [comment, exceptionItemsToAdd, exceptionListType, alertData]); + }, [comment, exceptionItemsToAdd, exceptionListType, retrieveAlertOsTypes]); const onAddExceptionConfirm = useCallback(() => { if (addOrUpdateExceptionItems !== null) { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx index aa36b65e04b694..0ef3350fbefc76 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx @@ -37,7 +37,7 @@ import { AddExceptionComments } from '../add_exception_comments'; import { enrichExceptionItemsWithComments, enrichExceptionItemsWithOS, - getOsTagValues, + getOperatingSystems, entryHasListType, entryHasNonEcsType, } from '../helpers'; @@ -135,6 +135,12 @@ export const EditExceptionModal = memo(function EditExceptionModal({ indexPatterns, ]); + useEffect(() => { + if (shouldDisableBulkClose === true) { + setShouldBulkCloseAlert(false); + } + }, [shouldDisableBulkClose]); + const handleBuilderOnChange = useCallback( ({ exceptionItems, @@ -167,7 +173,7 @@ export const EditExceptionModal = memo(function EditExceptionModal({ ...(comment !== '' ? [{ comment }] : []), ]); if (exceptionListType === 'endpoint') { - const osTypes = exceptionItem._tags ? getOsTagValues(exceptionItem._tags) : ['windows']; + const osTypes = exceptionItem._tags ? getOperatingSystems(exceptionItem._tags) : []; enriched = enrichExceptionItemsWithOS(enriched, osTypes); } return enriched; @@ -199,6 +205,8 @@ export const EditExceptionModal = memo(function EditExceptionModal({ {!isSignalIndexLoading && ( <> + {i18n.EXCEPTION_BUILDER_INFO} + { beforeEach(() => { @@ -248,6 +258,36 @@ describe('Exception helpers', () => { }); }); + describe('#getEntryValue', () => { + it('returns "match" entry value', () => { + const payload = getEntryMatchMock(); + const result = getEntryValue(payload); + const expected = 'some host name'; + expect(result).toEqual(expected); + }); + + it('returns "match any" entry values', () => { + const payload = getEntryMatchAnyMock(); + const result = getEntryValue(payload); + const expected = ['some host name']; + expect(result).toEqual(expected); + }); + + it('returns "exists" entry value', () => { + const payload = getEntryExistsMock(); + const result = getEntryValue(payload); + const expected = undefined; + expect(result).toEqual(expected); + }); + + it('returns "list" entry value', () => { + const payload = getEntryListMock(); + const result = getEntryValue(payload); + const expected = 'some-list-id'; + expect(result).toEqual(expected); + }); + }); + describe('#formatEntry', () => { test('it formats an entry', () => { const payload = getEntryMatchMock(); @@ -280,25 +320,55 @@ describe('Exception helpers', () => { test('it returns null if no operating system tag specified', () => { const result = getOperatingSystems(['some tag', 'some other tag']); - expect(result).toEqual(''); + expect(result).toEqual([]); }); test('it returns null if operating system tag malformed', () => { const result = getOperatingSystems(['some tag', 'jibberos:mac,windows', 'some other tag']); + expect(result).toEqual([]); + }); + + test('it returns operating systems if space included in os tag', () => { + const result = getOperatingSystems(['some tag', 'os: macos', 'some other tag']); + expect(result).toEqual(['macos']); + }); + + test('it returns operating systems if multiple os tags specified', () => { + const result = getOperatingSystems(['some tag', 'os: macos', 'some other tag', 'os:windows']); + expect(result).toEqual(['macos', 'windows']); + }); + }); + + describe('#formatOperatingSystems', () => { + test('it returns null if no operating system tag specified', () => { + const result = formatOperatingSystems(getOperatingSystems(['some tag', 'some other tag'])); + + expect(result).toEqual(''); + }); + + test('it returns null if operating system tag malformed', () => { + const result = formatOperatingSystems( + getOperatingSystems(['some tag', 'jibberos:mac,windows', 'some other tag']) + ); + expect(result).toEqual(''); }); test('it returns formatted operating systems if space included in os tag', () => { - const result = getOperatingSystems(['some tag', 'os: mac', 'some other tag']); + const result = formatOperatingSystems( + getOperatingSystems(['some tag', 'os: macos', 'some other tag']) + ); - expect(result).toEqual('Mac'); + expect(result).toEqual('macOS'); }); test('it returns formatted operating systems if multiple os tags specified', () => { - const result = getOperatingSystems(['some tag', 'os: mac', 'some other tag', 'os:windows']); + const result = formatOperatingSystems( + getOperatingSystems(['some tag', 'os: macos', 'some other tag', 'os:windows']) + ); - expect(result).toEqual('Mac, Windows'); + expect(result).toEqual('macOS, Windows'); }); }); @@ -441,4 +511,176 @@ describe('Exception helpers', () => { expect(exceptions).toEqual([{ ...rest, meta: undefined }]); }); }); + + describe('#formatExceptionItemForUpdate', () => { + test('it should return correct update fields', () => { + const payload = getExceptionListItemSchemaMock(); + const result = formatExceptionItemForUpdate(payload); + const expected = { + _tags: ['endpoint', 'process', 'malware', 'os:linux'], + comments: [], + description: 'This is a sample endpoint type exception', + entries: ENTRIES, + id: '1', + item_id: 'endpoint_list_item', + meta: {}, + name: 'Sample Endpoint Exception List', + namespace_type: 'single', + tags: ['user added string for a tag', 'malware'], + type: 'simple', + }; + expect(result).toEqual(expected); + }); + }); + + describe('#enrichExceptionItemsWithComments', () => { + test('it should add comments to an exception item', () => { + const payload = [getExceptionListItemSchemaMock()]; + const comments = getCommentsArrayMock(); + const result = enrichExceptionItemsWithComments(payload, comments); + const expected = [ + { + ...getExceptionListItemSchemaMock(), + comments: getCommentsArrayMock(), + }, + ]; + expect(result).toEqual(expected); + }); + + test('it should add comments to multiple exception items', () => { + const payload = [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()]; + const comments = getCommentsArrayMock(); + const result = enrichExceptionItemsWithComments(payload, comments); + const expected = [ + { + ...getExceptionListItemSchemaMock(), + comments: getCommentsArrayMock(), + }, + { + ...getExceptionListItemSchemaMock(), + comments: getCommentsArrayMock(), + }, + ]; + expect(result).toEqual(expected); + }); + }); + + describe('#enrichExceptionItemsWithOS', () => { + test('it should add an os tag to an exception item', () => { + const payload = [getExceptionListItemSchemaMock()]; + const osTypes = ['windows']; + const result = enrichExceptionItemsWithOS(payload, osTypes); + const expected = [ + { + ...getExceptionListItemSchemaMock(), + _tags: [...getExceptionListItemSchemaMock()._tags, 'os:windows'], + }, + ]; + expect(result).toEqual(expected); + }); + + test('it should add multiple os tags to all exception items', () => { + const payload = [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()]; + const osTypes = ['windows', 'macos']; + const result = enrichExceptionItemsWithOS(payload, osTypes); + const expected = [ + { + ...getExceptionListItemSchemaMock(), + _tags: [...getExceptionListItemSchemaMock()._tags, 'os:windows', 'os:macos'], + }, + { + ...getExceptionListItemSchemaMock(), + _tags: [...getExceptionListItemSchemaMock()._tags, 'os:windows', 'os:macos'], + }, + ]; + expect(result).toEqual(expected); + }); + + test('it should add os tag to all exception items without duplication', () => { + const payload = [ + { ...getExceptionListItemSchemaMock(), _tags: ['os:linux', 'os:windows'] }, + { ...getExceptionListItemSchemaMock(), _tags: ['os:linux'] }, + ]; + const osTypes = ['windows']; + const result = enrichExceptionItemsWithOS(payload, osTypes); + const expected = [ + { + ...getExceptionListItemSchemaMock(), + _tags: ['os:linux', 'os:windows'], + }, + { + ...getExceptionListItemSchemaMock(), + _tags: ['os:linux', 'os:windows'], + }, + ]; + expect(result).toEqual(expected); + }); + }); + + describe('#entryHasListType', () => { + test('it should return false with an empty array', () => { + const payload: ExceptionListItemSchema[] = []; + const result = entryHasListType(payload); + expect(result).toEqual(false); + }); + + test("it should return false with exception items that don't contain a list type", () => { + const payload = [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()]; + const result = entryHasListType(payload); + expect(result).toEqual(false); + }); + + test('it should return true with exception items that do contain a list type', () => { + const payload = [ + { + ...getExceptionListItemSchemaMock(), + entries: [{ type: OperatorTypeEnum.LIST }] as EntriesArray, + }, + getExceptionListItemSchemaMock(), + ]; + const result = entryHasListType(payload); + expect(result).toEqual(true); + }); + }); + + describe('#entryHasNonEcsType', () => { + const mockEcsIndexPattern = { + title: 'testIndex', + fields: [ + { + name: 'some.parentField', + }, + { + name: 'some.not.nested.field', + }, + { + name: 'nested.field', + }, + ], + } as IIndexPattern; + + test('it should return false with an empty array', () => { + const payload: ExceptionListItemSchema[] = []; + const result = entryHasNonEcsType(payload, mockEcsIndexPattern); + expect(result).toEqual(false); + }); + + test("it should return false with exception items that don't contain a non ecs type", () => { + const payload = [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()]; + const result = entryHasNonEcsType(payload, mockEcsIndexPattern); + expect(result).toEqual(false); + }); + + test('it should return true with exception items that do contain a non ecs type', () => { + const payload = [ + { + ...getExceptionListItemSchemaMock(), + entries: [{ field: 'some.nonEcsField' }] as EntriesArray, + }, + getExceptionListItemSchemaMock(), + ]; + const result = entryHasNonEcsType(payload, mockEcsIndexPattern); + expect(result).toEqual(true); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index db7cb5aeac8f09..481b2736b75975 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -14,7 +14,6 @@ import * as i18n from './translations'; import { FormattedEntry, BuilderEntry, - EmptyListEntry, DescriptionListItem, FormattedBuilderEntry, CreateExceptionListItemBuilderSchema, @@ -39,9 +38,6 @@ import { ExceptionListType, } from '../../../lists_plugin_deps'; import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; - -export const isListType = (item: BuilderEntry): item is EmptyListEntry => - item.type === OperatorTypeEnum.LIST; import { TimelineNonEcsData } from '../../../graphql/types'; import { WithCopyToClipboard } from '../../lib/clipboard/with_copy_to_clipboard'; @@ -82,11 +78,6 @@ export const getExceptionOperatorSelect = (item: BuilderEntry): OperatorOption = } }; -export const getExceptionOperatorFromSelect = (value: string): OperatorOption => { - const operator = EXCEPTION_OPERATORS.filter(({ message }) => message === value); - return operator[0] ?? isOperator; -}; - /** * Formats ExceptionItem entries into simple field, operator, value * for use in rendering items in table @@ -158,19 +149,32 @@ export const formatEntry = ({ }; }; -export const getOperatingSystems = (tags: string[]): string => { - const osMatches = tags - .filter((tag) => tag.startsWith('os:')) - .map((os) => capitalize(os.substring(3).trim())) - .join(', '); - - return osMatches; +/** + * Retrieves the values of tags marked as os + * + * @param tags an ExceptionItem's tags + */ +export const getOperatingSystems = (tags: string[]): string[] => { + return tags.filter((tag) => tag.startsWith('os:')).map((os) => os.substring(3).trim()); }; -export const getOsTagValues = (tags: string[]): string[] => { - return tags.filter((tag) => tag.startsWith('os:')).map((os) => os.substring(3).trim()); +/** + * Formats os value array to a displayable string + */ +export const formatOperatingSystems = (osTypes: string[]): string => { + return osTypes + .map((os) => { + if (os === 'macos') { + return 'macOS'; + } + return capitalize(os); + }) + .join(', '); }; +/** + * Returns all tags that match a given regex + */ export const getTagsInclude = ({ tags, regex, @@ -194,7 +198,7 @@ export const getDescriptionListContent = ( const details = [ { title: i18n.OPERATING_SYSTEM, - value: getOperatingSystems(exceptionItem._tags), + value: formatOperatingSystems(getOperatingSystems(exceptionItem._tags ?? [])), }, { title: i18n.DATE_CREATED, @@ -376,6 +380,11 @@ export const formatExceptionItemForUpdate = ( }; }; +/** + * Adds new and existing comments to all new exceptionItems if not present already + * @param exceptionItems new or existing ExceptionItem[] + * @param comments new Comments + */ export const enrichExceptionItemsWithComments = ( exceptionItems: Array, comments: Array @@ -388,6 +397,11 @@ export const enrichExceptionItemsWithComments = ( }); }; +/** + * Adds provided osTypes to all exceptionItems if not present already + * @param exceptionItems new or existing ExceptionItem[] + * @param osTypes array of os values + */ export const enrichExceptionItemsWithOS = ( exceptionItems: Array, osTypes: string[] @@ -402,18 +416,21 @@ export const enrichExceptionItemsWithOS = ( }); }; +/** + * Returns the value for the given fieldname within TimelineNonEcsData if it exists + */ export const getMappedNonEcsValue = ({ data, fieldName, }: { data: TimelineNonEcsData[]; fieldName: string; -}): string[] | undefined => { +}): string[] => { const item = data.find((d) => d.field === fieldName); if (item != null && item.value != null) { return item.value; } - return undefined; + return []; }; export const entryHasListType = ( @@ -421,7 +438,7 @@ export const entryHasListType = ( ) => { for (const { entries } of exceptionItems) { for (const exceptionEntry of entries ?? []) { - if (getOperatorType(exceptionEntry) === 'list') { + if (getOperatorType(exceptionEntry) === OperatorTypeEnum.LIST) { return true; } } @@ -429,16 +446,29 @@ export const entryHasListType = ( return false; }; +/** + * Determines whether or not any entries within the given exceptionItems contain values not in the specified ECS mapping + */ export const entryHasNonEcsType = ( exceptionItems: Array, indexPatterns: IIndexPattern ): boolean => { + const doesFieldNameExist = (exceptionEntry: Entry): boolean => { + return indexPatterns.fields.some(({ name }) => name === exceptionEntry.field); + }; + if (exceptionItems.length === 0) { return false; } for (const { entries } of exceptionItems) { for (const exceptionEntry of entries ?? []) { - if (indexPatterns.fields.find(({ name }) => name === exceptionEntry.field) === undefined) { + if (exceptionEntry.type === 'nested') { + for (const nestedExceptionEntry of exceptionEntry.entries) { + if (doesFieldNameExist(nestedExceptionEntry) === false) { + return true; + } + } + } else if (doesFieldNameExist(exceptionEntry) === false) { return true; } } @@ -446,19 +476,25 @@ export const entryHasNonEcsType = ( return false; }; +/** + * Returns the default values from the alert data to autofill new endpoint exceptions + */ export const defaultEndpointExceptionItems = ( listType: ExceptionListType, listId: string, ruleName: string, alertData: TimelineNonEcsData[] ): ExceptionsBuilderExceptionItem[] => { - const [filePath] = getMappedNonEcsValue({ data: alertData, fieldName: 'file.path' }) ?? []; - const [signatureSigner] = - getMappedNonEcsValue({ data: alertData, fieldName: 'file.Ext.code_signature.subject_name' }) ?? - []; - const [signatureTrusted] = - getMappedNonEcsValue({ data: alertData, fieldName: 'file.Ext.code_signature.trusted' }) ?? []; - const [sha1Hash] = getMappedNonEcsValue({ data: alertData, fieldName: 'file.hash.sha1' }) ?? []; + const [filePath] = getMappedNonEcsValue({ data: alertData, fieldName: 'file.path' }); + const [signatureSigner] = getMappedNonEcsValue({ + data: alertData, + fieldName: 'file.Ext.code_signature.subject_name', + }); + const [signatureTrusted] = getMappedNonEcsValue({ + data: alertData, + fieldName: 'file.Ext.code_signature.trusted', + }); + const [sha1Hash] = getMappedNonEcsValue({ data: alertData, fieldName: 'file.hash.sha1' }); const namespaceType = 'agnostic'; return [ @@ -483,7 +519,7 @@ export const defaultEndpointExceptionItems = ( value: signatureSigner ?? '', }, { - field: 'file.code_signature.trusted', + field: 'file.Ext.code_signature.trusted', operator: 'included', type: 'match', value: signatureTrusted ?? '', @@ -508,7 +544,7 @@ export const defaultEndpointExceptionItems = ( field: 'event.category', operator: 'included', type: 'match_any', - value: getMappedNonEcsValue({ data: alertData, fieldName: 'event.category' }) ?? [], + value: getMappedNonEcsValue({ data: alertData, fieldName: 'event.category' }), }, ], }, diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx index cdfa358a0f9c2b..3d9fe2ebaddae5 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx @@ -184,7 +184,11 @@ const ExceptionsViewerComponent = ({ [setCurrentModal] ); - const handleCloseExceptionModal = useCallback((): void => { + const handleOnCancelExceptionModal = useCallback((): void => { + setCurrentModal(null); + }, [setCurrentModal]); + + const handleOnConfirmExceptionModal = useCallback((): void => { setCurrentModal(null); handleFetchList(); }, [setCurrentModal, handleFetchList]); @@ -255,8 +259,8 @@ const ExceptionsViewerComponent = ({ ruleName={ruleName} exceptionListType={exceptionListTypeToEdit} exceptionItem={exceptionToEdit} - onCancel={handleCloseExceptionModal} - onConfirm={handleCloseExceptionModal} + onCancel={handleOnCancelExceptionModal} + onConfirm={handleOnConfirmExceptionModal} /> )} @@ -265,8 +269,8 @@ const ExceptionsViewerComponent = ({ ruleName={ruleName} ruleId={ruleId} exceptionListType={exceptionListTypeToEdit} - onCancel={handleCloseExceptionModal} - onConfirm={handleCloseExceptionModal} + onCancel={handleOnCancelExceptionModal} + onConfirm={handleOnConfirmExceptionModal} /> )} diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx index 697dff40129823..e95ea4531d9ad7 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx @@ -36,7 +36,7 @@ import { SetEventsLoadingProps, UpdateTimelineLoading, } from './types'; -import { Ecs } from '../../../graphql/types'; +import { Ecs, TimelineNonEcsData } from '../../../graphql/types'; import { AddExceptionOnClick } from '../../../common/components/exceptions/add_exception_modal'; import { getMappedNonEcsValue } from '../../../common/components/exceptions/helpers'; @@ -174,6 +174,8 @@ export const requiredFieldsForActions = [ 'signal.rule.query', 'signal.rule.to', 'signal.rule.id', + 'signal.original_event.kind', + 'signal.original_event.module', // Endpoint exception fields 'file.path', @@ -189,6 +191,7 @@ interface AlertActionArgs { createTimeline: CreateTimeline; dispatch: Dispatch; ecsRowData: Ecs; + nonEcsRowData: TimelineNonEcsData[]; hasIndexWrite: boolean; onAlertStatusUpdateFailure: (status: Status, error: Error) => void; onAlertStatusUpdateSuccess: (count: number, status: Status) => void; @@ -211,6 +214,7 @@ export const getAlertActions = ({ createTimeline, dispatch, ecsRowData, + nonEcsRowData, hasIndexWrite, onAlertStatusUpdateFailure, onAlertStatusUpdateSuccess, @@ -281,6 +285,18 @@ export const getAlertActions = ({ width: DEFAULT_ICON_BUTTON_WIDTH, }; + const isEndpointAlert = () => { + const [module] = getMappedNonEcsValue({ + data: nonEcsRowData, + fieldName: 'signal.original_event.module', + }); + const [kind] = getMappedNonEcsValue({ + data: nonEcsRowData, + fieldName: 'signal.original_event.kind', + }); + return module === 'endpoint' && kind === 'alert'; + }; + return [ { ...getInvestigateInResolverAction({ dispatch, timelineId }), @@ -305,15 +321,14 @@ export const getAlertActions = ({ ...(FILTER_OPEN !== status ? [openAlertActionComponent] : []), ...(FILTER_CLOSED !== status ? [closeAlertActionComponent] : []), ...(FILTER_IN_PROGRESS !== status ? [inProgressAlertActionComponent] : []), - // TODO: disable this option if the alert is not an Endpoint alert { onClick: ({ ecsData, data }: TimelineRowActionOnClick) => { - const ruleNameValue = getMappedNonEcsValue({ data, fieldName: 'signal.rule.name' }); - const ruleId = getMappedNonEcsValue({ data, fieldName: 'signal.rule.id' }); - if (ruleId !== undefined && ruleId.length > 0) { + const [ruleName] = getMappedNonEcsValue({ data, fieldName: 'signal.rule.name' }); + const [ruleId] = getMappedNonEcsValue({ data, fieldName: 'signal.rule.id' }); + if (ruleId !== undefined) { openAddExceptionModal({ - ruleName: ruleNameValue ? ruleNameValue[0] : '', - ruleId: ruleId[0], + ruleName: ruleName ?? '', + ruleId, exceptionListType: 'endpoint', alertData: { ecsData, @@ -323,7 +338,7 @@ export const getAlertActions = ({ } }, id: 'addEndpointException', - isActionDisabled: () => !canUserCRUD || !hasIndexWrite, + isActionDisabled: () => !canUserCRUD || !hasIndexWrite || !isEndpointAlert(), dataTestSubj: 'add-endpoint-exception-menu-item', ariaLabel: 'Add Endpoint Exception', content: {i18n.ACTION_ADD_ENDPOINT_EXCEPTION}, @@ -331,12 +346,12 @@ export const getAlertActions = ({ }, { onClick: ({ ecsData, data }: TimelineRowActionOnClick) => { - const ruleNameValue = getMappedNonEcsValue({ data, fieldName: 'signal.rule.name' }); - const ruleId = getMappedNonEcsValue({ data, fieldName: 'signal.rule.id' }); - if (ruleId !== undefined && ruleId.length > 0) { + const [ruleName] = getMappedNonEcsValue({ data, fieldName: 'signal.rule.name' }); + const [ruleId] = getMappedNonEcsValue({ data, fieldName: 'signal.rule.id' }); + if (ruleId !== undefined) { openAddExceptionModal({ - ruleName: ruleNameValue ? ruleNameValue[0] : '', - ruleId: ruleId[0], + ruleName: ruleName ?? '', + ruleId, exceptionListType: 'detection', alertData: { ecsData, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index 81aebe95930ac9..b9b963a84e966f 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -22,7 +22,10 @@ import { inputsSelectors, State, inputsModel } from '../../../common/store'; import { timelineActions, timelineSelectors } from '../../../timelines/store/timeline'; import { TimelineModel } from '../../../timelines/store/timeline/model'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; -import { useManageTimeline } from '../../../timelines/components/manage_timeline'; +import { + useManageTimeline, + TimelineRowActionArgs, +} from '../../../timelines/components/manage_timeline'; import { useApolloClient } from '../../../common/utils/apollo_context'; import { updateAlertStatusAction } from './actions'; @@ -48,7 +51,6 @@ import { displaySuccessToast, displayErrorToast, } from '../../../common/components/toasters'; -import { Ecs } from '../../../graphql/types'; import { getInvestigateInResolverAction } from '../../../timelines/components/timeline/body/helpers'; import { AddExceptionModal, @@ -321,12 +323,13 @@ export const AlertsTableComponent: React.FC = ({ // Send to Timeline / Update Alert Status Actions for each table row const additionalActions = useMemo( - () => (ecsRowData: Ecs) => + () => ({ ecsData, nonEcsData }: TimelineRowActionArgs) => getAlertActions({ apolloClient, canUserCRUD, createTimeline: createTimelineCallback, - ecsRowData, + ecsRowData: ecsData, + nonEcsRowData: nonEcsData, dispatch, hasIndexWrite, onAlertStatusUpdateFailure, @@ -401,9 +404,12 @@ export const AlertsTableComponent: React.FC = ({ closeAddExceptionModal(); }, [closeAddExceptionModal]); - const onAddExceptionConfirm = useCallback(() => { - closeAddExceptionModal(); - }, [closeAddExceptionModal]); + const onAddExceptionConfirm = useCallback( + (didCloseAlert: boolean) => { + closeAddExceptionModal(); + }, + [closeAddExceptionModal] + ); if (loading || isEmpty(signalsIndex)) { return ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx index 99d79a2441ccc0..7882185cbd9d65 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx @@ -14,7 +14,7 @@ import { SubsetTimelineModel } from '../../store/timeline/model'; import * as i18n from '../../../common/components/events_viewer/translations'; import * as i18nF from '../timeline/footer/translations'; import { timelineDefaults as timelineDefaultModel } from '../../store/timeline/defaults'; -import { Ecs } from '../../../graphql/types'; +import { Ecs, TimelineNonEcsData } from '../../../graphql/types'; interface ManageTimelineInit { documentType?: string; @@ -25,11 +25,16 @@ interface ManageTimelineInit { indexToAdd?: string[] | null; loadingText?: string; selectAll?: boolean; - timelineRowActions: (ecsData: Ecs) => TimelineRowAction[]; + timelineRowActions: ({ ecsData, nonEcsData }: TimelineRowActionArgs) => TimelineRowAction[]; title?: string; unit?: (totalCount: number) => string; } +export interface TimelineRowActionArgs { + ecsData: Ecs; + nonEcsData: TimelineNonEcsData[]; +} + interface ManageTimeline { documentType: string; defaultModel: SubsetTimelineModel; @@ -41,7 +46,7 @@ interface ManageTimeline { loadingText: string; queryFields: string[]; selectAll: boolean; - timelineRowActions: (ecsData: Ecs) => TimelineRowAction[]; + timelineRowActions: ({ ecsData, nonEcsData }: TimelineRowActionArgs) => TimelineRowAction[]; title: string; unit: (totalCount: number) => string; } @@ -71,7 +76,7 @@ type ActionManageTimeline = id: string; payload: { queryFields?: string[]; - timelineRowActions: (ecsData: Ecs) => TimelineRowAction[]; + timelineRowActions: ({ ecsData, nonEcsData }: TimelineRowActionArgs) => TimelineRowAction[]; }; }; @@ -142,7 +147,7 @@ interface UseTimelineManager { setTimelineRowActions: (actionsArgs: { id: string; queryFields?: string[]; - timelineRowActions: (ecsData: Ecs) => TimelineRowAction[]; + timelineRowActions: ({ ecsData, nonEcsData }: TimelineRowActionArgs) => TimelineRowAction[]; }) => void; } @@ -167,7 +172,7 @@ const useTimelineManager = (manageTimelineForTesting?: ManageTimelineById): UseT }: { id: string; queryFields?: string[]; - timelineRowActions: (ecsData: Ecs) => TimelineRowAction[]; + timelineRowActions: ({ ecsData, nonEcsData }: TimelineRowActionArgs) => TimelineRowAction[]; }) => { dispatch({ type: 'SET_TIMELINE_ACTIONS', diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx index a450d082cb85de..63de117aeaf3d5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx @@ -90,8 +90,8 @@ export const EventColumnView = React.memo( }) => { const { getManageTimelineById } = useManageTimeline(); const timelineActions = useMemo( - () => getManageTimelineById(timelineId).timelineRowActions(ecsData), - [ecsData, getManageTimelineById, timelineId] + () => getManageTimelineById(timelineId).timelineRowActions({ nonEcsData: data, ecsData }), + [data, ecsData, getManageTimelineById, timelineId] ); const [isPopoverOpen, setPopover] = useState(false); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx index 8f8f19020697cf..b474e4047eadd1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx @@ -33,6 +33,7 @@ import { Sort } from './sort'; import { useManageTimeline } from '../../manage_timeline'; import { GraphOverlay } from '../../graph_overlay'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../helpers'; +import { TimelineRowAction } from './actions'; export interface BodyProps { addNoteToEvent: AddNoteToEvent; @@ -104,7 +105,18 @@ export const Body = React.memo( const containerElementRef = useRef(null); const { getManageTimelineById } = useManageTimeline(); const timelineActions = useMemo( - () => (data.length > 0 ? getManageTimelineById(id).timelineRowActions(data[0].ecs) : []), + () => + data.reduce((acc: TimelineRowAction[], rowData) => { + const rowActions = getManageTimelineById(id).timelineRowActions({ + ecsData: rowData.ecs, + nonEcsData: rowData.data, + }); + return rowActions && + rowActions.filter((v) => v.displayType === 'icon').length > + acc.filter((v) => v.displayType === 'icon').length + ? rowActions + : acc; + }, []), [data, getManageTimelineById, id] ); From fa93a81ba67f5177024f1ab3b4ac68919a7824dc Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 9 Jul 2020 18:43:17 -0700 Subject: [PATCH 35/49] [kbn/optimizer] implement "requiredBundles" property of KP plugins (#70911) Co-authored-by: Josh Dover Co-authored-by: spalger Co-authored-by: Elastic Machine --- ...ana-plugin-core-server.discoveredplugin.md | 1 + ...server.discoveredplugin.requiredbundles.md | 18 +++++ ...ibana-plugin-core-server.pluginmanifest.md | 1 + ...e-server.pluginmanifest.requiredbundles.md | 18 +++++ examples/bfetch_explorer/kibana.json | 3 +- .../dashboard_embeddable_examples/kibana.json | 3 +- examples/embeddable_examples/kibana.json | 3 +- .../state_containers_examples/kibana.json | 3 +- examples/ui_action_examples/kibana.json | 3 +- examples/ui_actions_explorer/kibana.json | 3 +- .../mock_repo/plugins/bar/kibana.json | 3 +- .../bar/public/legacy/_other_styles.scss | 3 + .../plugins/bar/public/legacy/styles.scss | 2 + packages/kbn-optimizer/src/cli.ts | 10 ++- .../kbn-optimizer/src/common/bundle.test.ts | 2 + packages/kbn-optimizer/src/common/bundle.ts | 69 ++++++++++++++++ .../kbn-optimizer/src/common/bundle_cache.ts | 5 ++ .../kbn-optimizer/src/common/bundle_refs.ts | 4 + .../basic_optimization.test.ts.snap | 7 +- .../basic_optimization.test.ts | 6 ++ .../kbn-optimizer/src/log_optimizer_state.ts | 12 ++- .../assign_bundles_to_workers.test.ts | 80 +++++++++---------- .../optimizer/assign_bundles_to_workers.ts | 27 +++---- .../src/optimizer/filter_by_id.test.ts | 72 +++++++++++++++++ .../src/optimizer/filter_by_id.ts | 48 +++++++++++ .../src/optimizer/get_plugin_bundles.test.ts | 5 ++ .../src/optimizer/get_plugin_bundles.ts | 1 + .../optimizer/kibana_platform_plugins.test.ts | 4 + .../src/optimizer/kibana_platform_plugins.ts | 2 + .../src/optimizer/optimizer_config.test.ts | 45 ++++++++++- .../src/optimizer/optimizer_config.ts | 18 ++++- .../src/worker/bundle_ref_module.ts | 9 ++- .../src/worker/bundle_refs_plugin.ts | 62 ++++++++++++-- .../kbn-optimizer/src/worker/run_compilers.ts | 26 +++++- src/core/public/plugins/plugin.test.ts | 1 + .../public/plugins/plugins_service.test.ts | 1 + src/core/server/legacy/legacy_service.test.ts | 1 + .../discovery/plugin_manifest_parser.test.ts | 4 + .../discovery/plugin_manifest_parser.ts | 2 + .../integration_tests/plugins_service.test.ts | 3 + src/core/server/plugins/plugin.test.ts | 1 + src/core/server/plugins/plugin.ts | 2 + .../server/plugins/plugin_context.test.ts | 1 + .../server/plugins/plugins_service.test.ts | 6 ++ src/core/server/plugins/plugins_service.ts | 16 ++++ .../server/plugins/plugins_system.test.ts | 1 + src/core/server/plugins/plugins_system.ts | 1 + src/core/server/plugins/types.ts | 28 +++++++ src/core/server/server.api.md | 8 +- src/legacy/ui/ui_render/ui_render_mixin.js | 30 ++++--- src/plugins/advanced_settings/kibana.json | 3 +- src/plugins/bfetch/kibana.json | 3 +- src/plugins/charts/kibana.json | 3 +- src/plugins/console/kibana.json | 3 +- src/plugins/dashboard/kibana.json | 3 +- src/plugins/data/kibana.json | 8 +- src/plugins/discover/kibana.json | 9 ++- src/plugins/embeddable/kibana.json | 4 + src/plugins/es_ui_shared/kibana.json | 3 + src/plugins/expressions/kibana.json | 7 +- src/plugins/home/kibana.json | 5 +- .../index_pattern_management/kibana.json | 3 +- src/plugins/input_control_vis/kibana.json | 3 +- src/plugins/inspector/kibana.json | 3 +- src/plugins/kibana_react/kibana.json | 3 +- src/plugins/management/kibana.json | 3 +- src/plugins/maps_legacy/kibana.json | 3 +- src/plugins/region_map/kibana.json | 5 ++ src/plugins/saved_objects/kibana.json | 6 +- .../saved_objects_management/kibana.json | 3 +- src/plugins/share/kibana.json | 3 +- src/plugins/telemetry/kibana.json | 4 + src/plugins/tile_map/kibana.json | 5 ++ src/plugins/ui_actions/kibana.json | 3 + src/plugins/usage_collection/kibana.json | 5 +- src/plugins/vis_type_markdown/kibana.json | 3 +- src/plugins/vis_type_metric/kibana.json | 3 +- src/plugins/vis_type_table/kibana.json | 6 ++ src/plugins/vis_type_tagcloud/kibana.json | 3 +- src/plugins/vis_type_timelion/kibana.json | 3 +- src/plugins/vis_type_timeseries/kibana.json | 3 +- src/plugins/vis_type_vega/kibana.json | 3 +- src/plugins/vis_type_vislib/kibana.json | 3 +- src/plugins/visualizations/kibana.json | 3 +- src/plugins/visualize/kibana.json | 8 +- .../plugins/kbn_tp_run_pipeline/kibana.json | 5 +- .../plugins/app_link_test/kibana.json | 3 +- .../kbn_sample_panel_action/kibana.json | 5 +- test/scripts/jenkins_build_kibana.sh | 10 +-- test/scripts/jenkins_xpack_build_kibana.sh | 8 +- .../ui_actions_enhanced_examples/kibana.json | 6 +- x-pack/plugins/apm/kibana.json | 5 ++ x-pack/plugins/canvas/kibana.json | 3 +- .../cross_cluster_replication/kibana.json | 7 +- x-pack/plugins/dashboard_enhanced/kibana.json | 7 +- x-pack/plugins/data_enhanced/kibana.json | 3 +- x-pack/plugins/discover_enhanced/kibana.json | 3 +- x-pack/plugins/graph/kibana.json | 3 +- x-pack/plugins/grokdebugger/kibana.json | 5 +- .../index_lifecycle_management/kibana.json | 7 +- x-pack/plugins/index_management/kibana.json | 6 +- x-pack/plugins/infra/kibana.json | 9 ++- x-pack/plugins/ingest_manager/kibana.json | 3 +- x-pack/plugins/ingest_pipelines/kibana.json | 3 +- x-pack/plugins/lens/kibana.json | 3 +- x-pack/plugins/license_management/kibana.json | 6 +- x-pack/plugins/licensing/kibana.json | 3 +- x-pack/plugins/logstash/kibana.json | 3 +- x-pack/plugins/maps/kibana.json | 8 +- x-pack/plugins/ml/kibana.json | 10 ++- x-pack/plugins/monitoring/kibana.json | 3 +- x-pack/plugins/observability/kibana.json | 7 +- x-pack/plugins/painless_lab/kibana.json | 5 +- x-pack/plugins/remote_clusters/kibana.json | 6 +- x-pack/plugins/reporting/kibana.json | 6 +- x-pack/plugins/rollup/kibana.json | 9 ++- x-pack/plugins/searchprofiler/kibana.json | 3 +- x-pack/plugins/security/kibana.json | 10 ++- x-pack/plugins/security_solution/kibana.json | 7 +- x-pack/plugins/snapshot_restore/kibana.json | 6 +- x-pack/plugins/spaces/kibana.json | 8 +- x-pack/plugins/transform/kibana.json | 10 ++- .../plugins/triggers_actions_ui/kibana.json | 3 +- .../plugins/ui_actions_enhanced/kibana.json | 7 +- x-pack/plugins/upgrade_assistant/kibana.json | 3 +- x-pack/plugins/uptime/kibana.json | 11 ++- x-pack/plugins/watcher/kibana.json | 6 +- 127 files changed, 888 insertions(+), 181 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-core-server.discoveredplugin.requiredbundles.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.pluginmanifest.requiredbundles.md create mode 100644 packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/legacy/_other_styles.scss create mode 100644 packages/kbn-optimizer/src/optimizer/filter_by_id.test.ts create mode 100644 packages/kbn-optimizer/src/optimizer/filter_by_id.ts diff --git a/docs/development/core/server/kibana-plugin-core-server.discoveredplugin.md b/docs/development/core/server/kibana-plugin-core-server.discoveredplugin.md index 0e2b9bd60ab670..b88a179c5c4b3a 100644 --- a/docs/development/core/server/kibana-plugin-core-server.discoveredplugin.md +++ b/docs/development/core/server/kibana-plugin-core-server.discoveredplugin.md @@ -19,5 +19,6 @@ export interface DiscoveredPlugin | [configPath](./kibana-plugin-core-server.discoveredplugin.configpath.md) | ConfigPath | Root configuration path used by the plugin, defaults to "id" in snake\_case format. | | [id](./kibana-plugin-core-server.discoveredplugin.id.md) | PluginName | Identifier of the plugin. | | [optionalPlugins](./kibana-plugin-core-server.discoveredplugin.optionalplugins.md) | readonly PluginName[] | An optional list of the other plugins that if installed and enabled \*\*may be\*\* leveraged by this plugin for some additional functionality but otherwise are not required for this plugin to work properly. | +| [requiredBundles](./kibana-plugin-core-server.discoveredplugin.requiredbundles.md) | readonly PluginName[] | List of plugin ids that this plugin's UI code imports modules from that are not in requiredPlugins. | | [requiredPlugins](./kibana-plugin-core-server.discoveredplugin.requiredplugins.md) | readonly PluginName[] | An optional list of the other plugins that \*\*must be\*\* installed and enabled for this plugin to function properly. | diff --git a/docs/development/core/server/kibana-plugin-core-server.discoveredplugin.requiredbundles.md b/docs/development/core/server/kibana-plugin-core-server.discoveredplugin.requiredbundles.md new file mode 100644 index 00000000000000..6d54adb5236eaa --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.discoveredplugin.requiredbundles.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [DiscoveredPlugin](./kibana-plugin-core-server.discoveredplugin.md) > [requiredBundles](./kibana-plugin-core-server.discoveredplugin.requiredbundles.md) + +## DiscoveredPlugin.requiredBundles property + +List of plugin ids that this plugin's UI code imports modules from that are not in `requiredPlugins`. + +Signature: + +```typescript +readonly requiredBundles: readonly PluginName[]; +``` + +## Remarks + +The plugins listed here will be loaded in the browser, even if the plugin is disabled. Required by `@kbn/optimizer` to support cross-plugin imports. "core" and plugins already listed in `requiredPlugins` do not need to be duplicated here. + diff --git a/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md b/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md index 5edee51d6c523d..6db2f89590149f 100644 --- a/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md +++ b/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md @@ -25,6 +25,7 @@ Should never be used in code outside of Core but is exported for documentation p | [id](./kibana-plugin-core-server.pluginmanifest.id.md) | PluginName | Identifier of the plugin. Must be a string in camelCase. Part of a plugin public contract. Other plugins leverage it to access plugin API, navigate to the plugin, etc. | | [kibanaVersion](./kibana-plugin-core-server.pluginmanifest.kibanaversion.md) | string | The version of Kibana the plugin is compatible with, defaults to "version". | | [optionalPlugins](./kibana-plugin-core-server.pluginmanifest.optionalplugins.md) | readonly PluginName[] | An optional list of the other plugins that if installed and enabled \*\*may be\*\* leveraged by this plugin for some additional functionality but otherwise are not required for this plugin to work properly. | +| [requiredBundles](./kibana-plugin-core-server.pluginmanifest.requiredbundles.md) | readonly string[] | List of plugin ids that this plugin's UI code imports modules from that are not in requiredPlugins. | | [requiredPlugins](./kibana-plugin-core-server.pluginmanifest.requiredplugins.md) | readonly PluginName[] | An optional list of the other plugins that \*\*must be\*\* installed and enabled for this plugin to function properly. | | [server](./kibana-plugin-core-server.pluginmanifest.server.md) | boolean | Specifies whether plugin includes some server-side specific functionality. | | [ui](./kibana-plugin-core-server.pluginmanifest.ui.md) | boolean | Specifies whether plugin includes some client/browser specific functionality that should be included into client bundle via public/ui_plugin.js file. | diff --git a/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.requiredbundles.md b/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.requiredbundles.md new file mode 100644 index 00000000000000..98505d07101fe2 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.requiredbundles.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [PluginManifest](./kibana-plugin-core-server.pluginmanifest.md) > [requiredBundles](./kibana-plugin-core-server.pluginmanifest.requiredbundles.md) + +## PluginManifest.requiredBundles property + +List of plugin ids that this plugin's UI code imports modules from that are not in `requiredPlugins`. + +Signature: + +```typescript +readonly requiredBundles: readonly string[]; +``` + +## Remarks + +The plugins listed here will be loaded in the browser, even if the plugin is disabled. Required by `@kbn/optimizer` to support cross-plugin imports. "core" and plugins already listed in `requiredPlugins` do not need to be duplicated here. + diff --git a/examples/bfetch_explorer/kibana.json b/examples/bfetch_explorer/kibana.json index 0039e9647bf83f..f32cdfc13a1fe2 100644 --- a/examples/bfetch_explorer/kibana.json +++ b/examples/bfetch_explorer/kibana.json @@ -5,5 +5,6 @@ "server": true, "ui": true, "requiredPlugins": ["bfetch", "developerExamples"], - "optionalPlugins": [] + "optionalPlugins": [], + "requiredBundles": ["kibanaReact"] } diff --git a/examples/dashboard_embeddable_examples/kibana.json b/examples/dashboard_embeddable_examples/kibana.json index bb2ced569edb5c..807229fad9dcf9 100644 --- a/examples/dashboard_embeddable_examples/kibana.json +++ b/examples/dashboard_embeddable_examples/kibana.json @@ -5,5 +5,6 @@ "server": false, "ui": true, "requiredPlugins": ["embeddable", "embeddableExamples", "dashboard", "developerExamples"], - "optionalPlugins": [] + "optionalPlugins": [], + "requiredBundles": ["esUiShared"] } diff --git a/examples/embeddable_examples/kibana.json b/examples/embeddable_examples/kibana.json index 8ae04c1f6c6444..771c19cfdbd3dd 100644 --- a/examples/embeddable_examples/kibana.json +++ b/examples/embeddable_examples/kibana.json @@ -6,5 +6,6 @@ "ui": true, "requiredPlugins": ["embeddable", "uiActions"], "optionalPlugins": [], - "extraPublicDirs": ["public/todo", "public/hello_world", "public/todo/todo_ref_embeddable"] + "extraPublicDirs": ["public/todo", "public/hello_world", "public/todo/todo_ref_embeddable"], + "requiredBundles": ["kibanaReact"] } diff --git a/examples/state_containers_examples/kibana.json b/examples/state_containers_examples/kibana.json index 66da207cb4e772..58346af8f1d191 100644 --- a/examples/state_containers_examples/kibana.json +++ b/examples/state_containers_examples/kibana.json @@ -5,5 +5,6 @@ "server": true, "ui": true, "requiredPlugins": ["navigation", "data", "developerExamples"], - "optionalPlugins": [] + "optionalPlugins": [], + "requiredBundles": ["kibanaUtils", "kibanaReact"] } diff --git a/examples/ui_action_examples/kibana.json b/examples/ui_action_examples/kibana.json index cd12442daf61c0..0e0b6b6830b950 100644 --- a/examples/ui_action_examples/kibana.json +++ b/examples/ui_action_examples/kibana.json @@ -5,5 +5,6 @@ "server": false, "ui": true, "requiredPlugins": ["uiActions"], - "optionalPlugins": [] + "optionalPlugins": [], + "requiredBundles": ["kibanaReact"] } diff --git a/examples/ui_actions_explorer/kibana.json b/examples/ui_actions_explorer/kibana.json index f57072e89b06dd..0a55e603747103 100644 --- a/examples/ui_actions_explorer/kibana.json +++ b/examples/ui_actions_explorer/kibana.json @@ -5,5 +5,6 @@ "server": false, "ui": true, "requiredPlugins": ["uiActions", "uiActionsExamples", "developerExamples"], - "optionalPlugins": [] + "optionalPlugins": [], + "requiredBundles": ["kibanaReact"] } diff --git a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/kibana.json b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/kibana.json index 20c8046daa65e5..33f53e336598d4 100644 --- a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/kibana.json +++ b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/kibana.json @@ -1,4 +1,5 @@ { "id": "bar", - "ui": true + "ui": true, + "requiredBundles": ["foo"] } diff --git a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/legacy/_other_styles.scss b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/legacy/_other_styles.scss new file mode 100644 index 00000000000000..2c1b9562b9567e --- /dev/null +++ b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/legacy/_other_styles.scss @@ -0,0 +1,3 @@ +p { + background-color: rebeccapurple; +} diff --git a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/legacy/styles.scss b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/legacy/styles.scss index e71a2d485a2f85..1dc7bbe9daeb07 100644 --- a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/legacy/styles.scss +++ b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/legacy/styles.scss @@ -1,3 +1,5 @@ +@import "./other_styles.scss"; + body { width: $globalStyleConstant; background-image: url("ui/icon.svg"); diff --git a/packages/kbn-optimizer/src/cli.ts b/packages/kbn-optimizer/src/cli.ts index 0916f12a7110da..9d3f4b88a258f9 100644 --- a/packages/kbn-optimizer/src/cli.ts +++ b/packages/kbn-optimizer/src/cli.ts @@ -87,6 +87,11 @@ run( throw createFlagError('expected --report-stats to have no value'); } + const filter = typeof flags.filter === 'string' ? [flags.filter] : flags.filter; + if (!Array.isArray(filter) || !filter.every((f) => typeof f === 'string')) { + throw createFlagError('expected --filter to be one or more strings'); + } + const config = OptimizerConfig.create({ repoRoot: REPO_ROOT, watch, @@ -99,6 +104,7 @@ run( extraPluginScanDirs, inspectWorkers, includeCoreBundle, + filter, }); let update$ = runOptimizer(config); @@ -128,12 +134,13 @@ run( 'inspect-workers', 'report-stats', ], - string: ['workers', 'scan-dir'], + string: ['workers', 'scan-dir', 'filter'], default: { core: true, examples: true, cache: true, 'inspect-workers': true, + filter: [], }, help: ` --watch run the optimizer in watch mode @@ -142,6 +149,7 @@ run( --profile profile the webpack builds and write stats.json files to build outputs --no-core disable generating the core bundle --no-cache disable the cache + --filter comma-separated list of bundle id filters, results from multiple flags are merged, * and ! are supported --no-examples don't build the example plugins --dist create bundles that are suitable for inclusion in the Kibana distributable --scan-dir add a directory to the list of directories scanned for plugins (specify as many times as necessary) diff --git a/packages/kbn-optimizer/src/common/bundle.test.ts b/packages/kbn-optimizer/src/common/bundle.test.ts index b209bbca25ac4d..6197a084858548 100644 --- a/packages/kbn-optimizer/src/common/bundle.test.ts +++ b/packages/kbn-optimizer/src/common/bundle.test.ts @@ -50,6 +50,7 @@ it('creates cache keys', () => { "spec": Object { "contextDir": "/foo/bar", "id": "bar", + "manifestPath": undefined, "outputDir": "/foo/bar/target", "publicDirNames": Array [ "public", @@ -85,6 +86,7 @@ it('parses bundles from JSON specs', () => { }, "contextDir": "/foo/bar", "id": "bar", + "manifestPath": undefined, "outputDir": "/foo/bar/target", "publicDirNames": Array [ "public", diff --git a/packages/kbn-optimizer/src/common/bundle.ts b/packages/kbn-optimizer/src/common/bundle.ts index 80af94c30f8da4..a354da7a21521f 100644 --- a/packages/kbn-optimizer/src/common/bundle.ts +++ b/packages/kbn-optimizer/src/common/bundle.ts @@ -18,6 +18,7 @@ */ import Path from 'path'; +import Fs from 'fs'; import { BundleCache } from './bundle_cache'; import { UnknownVals } from './ts_helpers'; @@ -25,6 +26,11 @@ import { includes, ascending, entriesToObject } from './array_helpers'; const VALID_BUNDLE_TYPES = ['plugin' as const, 'entry' as const]; +const DEFAULT_IMPLICIT_BUNDLE_DEPS = ['core']; + +const isStringArray = (input: any): input is string[] => + Array.isArray(input) && input.every((x) => typeof x === 'string'); + export interface BundleSpec { readonly type: typeof VALID_BUNDLE_TYPES[0]; /** Unique id for this bundle */ @@ -37,6 +43,8 @@ export interface BundleSpec { readonly sourceRoot: string; /** Absolute path to the directory where output should be written */ readonly outputDir: string; + /** Absolute path to a kibana.json manifest file, if omitted we assume there are not dependenices */ + readonly manifestPath?: string; } export class Bundle { @@ -56,6 +64,12 @@ export class Bundle { public readonly sourceRoot: BundleSpec['sourceRoot']; /** Absolute path to the output directory for this bundle */ public readonly outputDir: BundleSpec['outputDir']; + /** + * Absolute path to a manifest file with "requiredBundles" which will be + * used to allow bundleRefs from this bundle to the exports of another bundle. + * Every bundle mentioned in the `requiredBundles` must be built together. + */ + public readonly manifestPath: BundleSpec['manifestPath']; public readonly cache: BundleCache; @@ -66,6 +80,7 @@ export class Bundle { this.contextDir = spec.contextDir; this.sourceRoot = spec.sourceRoot; this.outputDir = spec.outputDir; + this.manifestPath = spec.manifestPath; this.cache = new BundleCache(Path.resolve(this.outputDir, '.kbn-optimizer-cache')); } @@ -96,8 +111,54 @@ export class Bundle { contextDir: this.contextDir, sourceRoot: this.sourceRoot, outputDir: this.outputDir, + manifestPath: this.manifestPath, }; } + + readBundleDeps(): { implicit: string[]; explicit: string[] } { + if (!this.manifestPath) { + return { + implicit: [...DEFAULT_IMPLICIT_BUNDLE_DEPS], + explicit: [], + }; + } + + let json: string; + try { + json = Fs.readFileSync(this.manifestPath, 'utf8'); + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + + json = '{}'; + } + + let parsedManifest: { requiredPlugins?: string[]; requiredBundles?: string[] }; + try { + parsedManifest = JSON.parse(json); + } catch (error) { + throw new Error( + `unable to parse manifest at [${this.manifestPath}], error: [${error.message}]` + ); + } + + if (typeof parsedManifest === 'object' && parsedManifest) { + const explicit = parsedManifest.requiredBundles || []; + const implicit = [...DEFAULT_IMPLICIT_BUNDLE_DEPS, ...(parsedManifest.requiredPlugins || [])]; + + if (isStringArray(explicit) && isStringArray(implicit)) { + return { + explicit, + implicit, + }; + } + } + + throw new Error( + `Expected "requiredBundles" and "requiredPlugins" in manifest file [${this.manifestPath}] to be arrays of strings` + ); + } } /** @@ -152,6 +213,13 @@ export function parseBundles(json: string) { throw new Error('`bundles[]` must have an absolute path `outputDir` property'); } + const { manifestPath } = spec; + if (manifestPath !== undefined) { + if (!(typeof manifestPath === 'string' && Path.isAbsolute(manifestPath))) { + throw new Error('`bundles[]` must have an absolute path `manifestPath` property'); + } + } + return new Bundle({ type, id, @@ -159,6 +227,7 @@ export function parseBundles(json: string) { contextDir, sourceRoot, outputDir, + manifestPath, }); } ); diff --git a/packages/kbn-optimizer/src/common/bundle_cache.ts b/packages/kbn-optimizer/src/common/bundle_cache.ts index 5ae3e4c28a2018..7607e270b5b4f7 100644 --- a/packages/kbn-optimizer/src/common/bundle_cache.ts +++ b/packages/kbn-optimizer/src/common/bundle_cache.ts @@ -24,6 +24,7 @@ export interface State { optimizerCacheKey?: unknown; cacheKey?: unknown; moduleCount?: number; + workUnits?: number; files?: string[]; bundleRefExportIds?: string[]; } @@ -96,6 +97,10 @@ export class BundleCache { return this.get().cacheKey; } + public getWorkUnits() { + return this.get().workUnits; + } + public getOptimizerCacheKey() { return this.get().optimizerCacheKey; } diff --git a/packages/kbn-optimizer/src/common/bundle_refs.ts b/packages/kbn-optimizer/src/common/bundle_refs.ts index a5c60f2031c0b6..85731f32f8991f 100644 --- a/packages/kbn-optimizer/src/common/bundle_refs.ts +++ b/packages/kbn-optimizer/src/common/bundle_refs.ts @@ -114,6 +114,10 @@ export class BundleRefs { constructor(private readonly refs: BundleRef[]) {} + public forBundleIds(bundleIds: string[]) { + return this.refs.filter((r) => bundleIds.includes(r.bundleId)); + } + public filterByExportIds(exportIds: string[]) { return this.refs.filter((r) => exportIds.includes(r.exportId)); } diff --git a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap index 211cfac3806ad7..c52873ab7ec20f 100644 --- a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap +++ b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap @@ -10,6 +10,7 @@ OptimizerConfig { }, "contextDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar, "id": "bar", + "manifestPath": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/kibana.json, "outputDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/target/public, "publicDirNames": Array [ "public", @@ -24,6 +25,7 @@ OptimizerConfig { }, "contextDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo, "id": "foo", + "manifestPath": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/kibana.json, "outputDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/target/public, "publicDirNames": Array [ "public", @@ -42,18 +44,21 @@ OptimizerConfig { "extraPublicDirs": Array [], "id": "bar", "isUiPlugin": true, + "manifestPath": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/kibana.json, }, Object { "directory": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo, "extraPublicDirs": Array [], "id": "foo", "isUiPlugin": true, + "manifestPath": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/kibana.json, }, Object { "directory": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/nested/baz, "extraPublicDirs": Array [], "id": "baz", "isUiPlugin": false, + "manifestPath": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/nested/baz/kibana.json, }, ], "profileWebpack": false, @@ -66,7 +71,7 @@ OptimizerConfig { } `; -exports[`prepares assets for distribution: bar bundle 1`] = `"(function(modules){var installedModules={};function __webpack_require__(moduleId){if(installedModules[moduleId]){return installedModules[moduleId].exports}var module=installedModules[moduleId]={i:moduleId,l:false,exports:{}};modules[moduleId].call(module.exports,module,module.exports,__webpack_require__);module.l=true;return module.exports}__webpack_require__.m=modules;__webpack_require__.c=installedModules;__webpack_require__.d=function(exports,name,getter){if(!__webpack_require__.o(exports,name)){Object.defineProperty(exports,name,{enumerable:true,get:getter})}};__webpack_require__.r=function(exports){if(typeof Symbol!==\\"undefined\\"&&Symbol.toStringTag){Object.defineProperty(exports,Symbol.toStringTag,{value:\\"Module\\"})}Object.defineProperty(exports,\\"__esModule\\",{value:true})};__webpack_require__.t=function(value,mode){if(mode&1)value=__webpack_require__(value);if(mode&8)return value;if(mode&4&&typeof value===\\"object\\"&&value&&value.__esModule)return value;var ns=Object.create(null);__webpack_require__.r(ns);Object.defineProperty(ns,\\"default\\",{enumerable:true,value:value});if(mode&2&&typeof value!=\\"string\\")for(var key in value)__webpack_require__.d(ns,key,function(key){return value[key]}.bind(null,key));return ns};__webpack_require__.n=function(module){var getter=module&&module.__esModule?function getDefault(){return module[\\"default\\"]}:function getModuleExports(){return module};__webpack_require__.d(getter,\\"a\\",getter);return getter};__webpack_require__.o=function(object,property){return Object.prototype.hasOwnProperty.call(object,property)};__webpack_require__.p=\\"\\";return __webpack_require__(__webpack_require__.s=5)})([function(module,exports,__webpack_require__){\\"use strict\\";var isOldIE=function isOldIE(){var memo;return function memorize(){if(typeof memo===\\"undefined\\"){memo=Boolean(window&&document&&document.all&&!window.atob)}return memo}}();var getTarget=function getTarget(){var memo={};return function memorize(target){if(typeof memo[target]===\\"undefined\\"){var styleTarget=document.querySelector(target);if(window.HTMLIFrameElement&&styleTarget instanceof window.HTMLIFrameElement){try{styleTarget=styleTarget.contentDocument.head}catch(e){styleTarget=null}}memo[target]=styleTarget}return memo[target]}}();var stylesInDom=[];function getIndexByIdentifier(identifier){var result=-1;for(var i=0;i { expect(foo.cache.getModuleCount()).toBe(6); expect(foo.cache.getReferencedFiles()).toMatchInlineSnapshot(` Array [ + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/kibana.json, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/async_import.ts, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/ext.ts, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/index.ts, @@ -160,12 +161,17 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { Array [ /node_modules/css-loader/package.json, /node_modules/style-loader/package.json, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/kibana.json, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/index.scss, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/index.ts, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/legacy/_other_styles.scss, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/legacy/styles.scss, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/lib.ts, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/src/legacy/ui/public/icon.svg, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/src/legacy/ui/public/styles/_globals_v7dark.scss, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/src/legacy/ui/public/styles/_globals_v7light.scss, /packages/kbn-optimizer/target/worker/entry_point_creator.js, + /packages/kbn-optimizer/target/worker/postcss.config.js, /packages/kbn-ui-shared-deps/public_path_module_creator.js, ] `); diff --git a/packages/kbn-optimizer/src/log_optimizer_state.ts b/packages/kbn-optimizer/src/log_optimizer_state.ts index 23767be610da4f..20d98f74dbe860 100644 --- a/packages/kbn-optimizer/src/log_optimizer_state.ts +++ b/packages/kbn-optimizer/src/log_optimizer_state.ts @@ -54,12 +54,18 @@ export function logOptimizerState(log: ToolingLog, config: OptimizerConfig) { if (event?.type === 'worker started') { let moduleCount = 0; + let workUnits = 0; for (const bundle of event.bundles) { moduleCount += bundle.cache.getModuleCount() ?? NaN; + workUnits += bundle.cache.getWorkUnits() ?? NaN; } - const mcString = isFinite(moduleCount) ? String(moduleCount) : '?'; - const bcString = String(event.bundles.length); - log.info(`starting worker [${bcString} bundles, ${mcString} modules]`); + + log.info( + `starting worker [${event.bundles.length} ${ + event.bundles.length === 1 ? 'bundle' : 'bundles' + }]` + ); + log.debug(`modules [${moduleCount}] work units [${workUnits}]`); } if (state.phase === 'reallocating') { diff --git a/packages/kbn-optimizer/src/optimizer/assign_bundles_to_workers.test.ts b/packages/kbn-optimizer/src/optimizer/assign_bundles_to_workers.test.ts index ca50a49e269139..5443a88eb1a634 100644 --- a/packages/kbn-optimizer/src/optimizer/assign_bundles_to_workers.test.ts +++ b/packages/kbn-optimizer/src/optimizer/assign_bundles_to_workers.test.ts @@ -23,11 +23,11 @@ import { Bundle } from '../common'; import { assignBundlesToWorkers, Assignments } from './assign_bundles_to_workers'; -const hasModuleCount = (b: Bundle) => b.cache.getModuleCount() !== undefined; -const noModuleCount = (b: Bundle) => b.cache.getModuleCount() === undefined; +const hasWorkUnits = (b: Bundle) => b.cache.getWorkUnits() !== undefined; +const noWorkUnits = (b: Bundle) => b.cache.getWorkUnits() === undefined; const summarizeBundles = (w: Assignments) => [ - w.moduleCount ? `${w.moduleCount} known modules` : '', + w.workUnits ? `${w.workUnits} work units` : '', w.newBundles ? `${w.newBundles} new bundles` : '', ] .filter(Boolean) @@ -42,15 +42,15 @@ const assertReturnVal = (workers: Assignments[]) => { expect(workers).toBeInstanceOf(Array); for (const worker of workers) { expect(worker).toEqual({ - moduleCount: expect.any(Number), + workUnits: expect.any(Number), newBundles: expect.any(Number), bundles: expect.any(Array), }); - expect(worker.bundles.filter(noModuleCount).length).toBe(worker.newBundles); + expect(worker.bundles.filter(noWorkUnits).length).toBe(worker.newBundles); expect( - worker.bundles.filter(hasModuleCount).reduce((sum, b) => sum + b.cache.getModuleCount()!, 0) - ).toBe(worker.moduleCount); + worker.bundles.filter(hasWorkUnits).reduce((sum, b) => sum + b.cache.getWorkUnits()!, 0) + ).toBe(worker.workUnits); } }; @@ -76,7 +76,7 @@ const getBundles = ({ for (let i = 1; i <= withCounts; i++) { const id = `foo${i}`; const bundle = testBundle(id); - bundle.cache.set({ moduleCount: i % 5 === 0 ? i * 10 : i }); + bundle.cache.set({ workUnits: i % 5 === 0 ? i * 10 : i }); bundles.push(bundle); } @@ -95,8 +95,8 @@ it('creates less workers if maxWorkersCount is larger than bundle count', () => expect(workers.length).toBe(2); expect(readConfigs(workers)).toMatchInlineSnapshot(` Array [ - "worker 0 (1 known modules) => foo1", - "worker 1 (2 known modules) => foo2", + "worker 0 (1 work units) => foo1", + "worker 1 (2 work units) => foo2", ] `); }); @@ -121,10 +121,10 @@ it('distributes bundles without module counts evenly after assigning modules wit assertReturnVal(workers); expect(readConfigs(workers)).toMatchInlineSnapshot(` Array [ - "worker 0 (78 known modules, 3 new bundles) => foo5,foo11,foo8,foo6,foo2,foo1,bar9,bar5,bar1", - "worker 1 (78 known modules, 3 new bundles) => foo16,foo14,foo13,foo12,foo9,foo7,foo4,foo3,bar8,bar4,bar0", - "worker 2 (100 known modules, 2 new bundles) => foo10,bar7,bar3", - "worker 3 (150 known modules, 2 new bundles) => foo15,bar6,bar2", + "worker 0 (78 work units, 3 new bundles) => foo5,foo11,foo8,foo6,foo2,foo1,bar9,bar5,bar1", + "worker 1 (78 work units, 3 new bundles) => foo16,foo14,foo13,foo12,foo9,foo7,foo4,foo3,bar8,bar4,bar0", + "worker 2 (100 work units, 2 new bundles) => foo10,bar7,bar3", + "worker 3 (150 work units, 2 new bundles) => foo15,bar6,bar2", ] `); }); @@ -135,8 +135,8 @@ it('distributes 2 bundles to workers evenly', () => { assertReturnVal(workers); expect(readConfigs(workers)).toMatchInlineSnapshot(` Array [ - "worker 0 (1 known modules) => foo1", - "worker 1 (2 known modules) => foo2", + "worker 0 (1 work units) => foo1", + "worker 1 (2 work units) => foo2", ] `); }); @@ -147,10 +147,10 @@ it('distributes 5 bundles to workers evenly', () => { assertReturnVal(workers); expect(readConfigs(workers)).toMatchInlineSnapshot(` Array [ - "worker 0 (3 known modules) => foo2,foo1", - "worker 1 (3 known modules) => foo3", - "worker 2 (4 known modules) => foo4", - "worker 3 (50 known modules) => foo5", + "worker 0 (3 work units) => foo2,foo1", + "worker 1 (3 work units) => foo3", + "worker 2 (4 work units) => foo4", + "worker 3 (50 work units) => foo5", ] `); }); @@ -161,10 +161,10 @@ it('distributes 10 bundles to workers evenly', () => { assertReturnVal(workers); expect(readConfigs(workers)).toMatchInlineSnapshot(` Array [ - "worker 0 (20 known modules) => foo9,foo6,foo4,foo1", - "worker 1 (20 known modules) => foo8,foo7,foo3,foo2", - "worker 2 (50 known modules) => foo5", - "worker 3 (100 known modules) => foo10", + "worker 0 (20 work units) => foo9,foo6,foo4,foo1", + "worker 1 (20 work units) => foo8,foo7,foo3,foo2", + "worker 2 (50 work units) => foo5", + "worker 3 (100 work units) => foo10", ] `); }); @@ -175,10 +175,10 @@ it('distributes 15 bundles to workers evenly', () => { assertReturnVal(workers); expect(readConfigs(workers)).toMatchInlineSnapshot(` Array [ - "worker 0 (70 known modules) => foo14,foo13,foo12,foo11,foo9,foo6,foo4,foo1", - "worker 1 (70 known modules) => foo5,foo8,foo7,foo3,foo2", - "worker 2 (100 known modules) => foo10", - "worker 3 (150 known modules) => foo15", + "worker 0 (70 work units) => foo14,foo13,foo12,foo11,foo9,foo6,foo4,foo1", + "worker 1 (70 work units) => foo5,foo8,foo7,foo3,foo2", + "worker 2 (100 work units) => foo10", + "worker 3 (150 work units) => foo15", ] `); }); @@ -189,10 +189,10 @@ it('distributes 20 bundles to workers evenly', () => { assertReturnVal(workers); expect(readConfigs(workers)).toMatchInlineSnapshot(` Array [ - "worker 0 (153 known modules) => foo15,foo3", - "worker 1 (153 known modules) => foo10,foo16,foo13,foo11,foo7,foo6", - "worker 2 (154 known modules) => foo5,foo19,foo18,foo17,foo14,foo12,foo9,foo8,foo4,foo2,foo1", - "worker 3 (200 known modules) => foo20", + "worker 0 (153 work units) => foo15,foo3", + "worker 1 (153 work units) => foo10,foo16,foo13,foo11,foo7,foo6", + "worker 2 (154 work units) => foo5,foo19,foo18,foo17,foo14,foo12,foo9,foo8,foo4,foo2,foo1", + "worker 3 (200 work units) => foo20", ] `); }); @@ -203,10 +203,10 @@ it('distributes 25 bundles to workers evenly', () => { assertReturnVal(workers); expect(readConfigs(workers)).toMatchInlineSnapshot(` Array [ - "worker 0 (250 known modules) => foo20,foo17,foo13,foo9,foo8,foo2,foo1", - "worker 1 (250 known modules) => foo15,foo23,foo22,foo18,foo16,foo11,foo7,foo3", - "worker 2 (250 known modules) => foo10,foo5,foo24,foo21,foo19,foo14,foo12,foo6,foo4", - "worker 3 (250 known modules) => foo25", + "worker 0 (250 work units) => foo20,foo17,foo13,foo9,foo8,foo2,foo1", + "worker 1 (250 work units) => foo15,foo23,foo22,foo18,foo16,foo11,foo7,foo3", + "worker 2 (250 work units) => foo10,foo5,foo24,foo21,foo19,foo14,foo12,foo6,foo4", + "worker 3 (250 work units) => foo25", ] `); }); @@ -217,10 +217,10 @@ it('distributes 30 bundles to workers evenly', () => { assertReturnVal(workers); expect(readConfigs(workers)).toMatchInlineSnapshot(` Array [ - "worker 0 (352 known modules) => foo30,foo22,foo14,foo11,foo4,foo1", - "worker 1 (352 known modules) => foo15,foo10,foo28,foo24,foo19,foo16,foo9,foo6", - "worker 2 (353 known modules) => foo20,foo5,foo29,foo23,foo21,foo13,foo12,foo3,foo2", - "worker 3 (353 known modules) => foo25,foo27,foo26,foo18,foo17,foo8,foo7", + "worker 0 (352 work units) => foo30,foo22,foo14,foo11,foo4,foo1", + "worker 1 (352 work units) => foo15,foo10,foo28,foo24,foo19,foo16,foo9,foo6", + "worker 2 (353 work units) => foo20,foo5,foo29,foo23,foo21,foo13,foo12,foo3,foo2", + "worker 3 (353 work units) => foo25,foo27,foo26,foo18,foo17,foo8,foo7", ] `); }); diff --git a/packages/kbn-optimizer/src/optimizer/assign_bundles_to_workers.ts b/packages/kbn-optimizer/src/optimizer/assign_bundles_to_workers.ts index e1bcb22230bf9f..44a3b21c5fd470 100644 --- a/packages/kbn-optimizer/src/optimizer/assign_bundles_to_workers.ts +++ b/packages/kbn-optimizer/src/optimizer/assign_bundles_to_workers.ts @@ -20,19 +20,18 @@ import { Bundle, descending, ascending } from '../common'; // helper types used inside getWorkerConfigs so we don't have -// to calculate moduleCounts over and over - +// to calculate workUnits over and over export interface Assignments { - moduleCount: number; + workUnits: number; newBundles: number; bundles: Bundle[]; } /** assign a wrapped bundle to a worker */ const assignBundle = (worker: Assignments, bundle: Bundle) => { - const moduleCount = bundle.cache.getModuleCount(); - if (moduleCount !== undefined) { - worker.moduleCount += moduleCount; + const workUnits = bundle.cache.getWorkUnits(); + if (workUnits !== undefined) { + worker.workUnits += workUnits; } else { worker.newBundles += 1; } @@ -59,7 +58,7 @@ export function assignBundlesToWorkers(bundles: Bundle[], maxWorkerCount: number const workers: Assignments[] = []; for (let i = 0; i < workerCount; i++) { workers.push({ - moduleCount: 0, + workUnits: 0, newBundles: 0, bundles: [], }); @@ -67,18 +66,18 @@ export function assignBundlesToWorkers(bundles: Bundle[], maxWorkerCount: number /** * separate the bundles which do and don't have module - * counts and sort them by [moduleCount, id] + * counts and sort them by [workUnits, id] */ const bundlesWithCountsDesc = bundles - .filter((b) => b.cache.getModuleCount() !== undefined) + .filter((b) => b.cache.getWorkUnits() !== undefined) .sort( descending( - (b) => b.cache.getModuleCount(), + (b) => b.cache.getWorkUnits(), (b) => b.id ) ); const bundlesWithoutModuleCounts = bundles - .filter((b) => b.cache.getModuleCount() === undefined) + .filter((b) => b.cache.getWorkUnits() === undefined) .sort(descending((b) => b.id)); /** @@ -87,9 +86,9 @@ export function assignBundlesToWorkers(bundles: Bundle[], maxWorkerCount: number * with module counts are assigned */ while (bundlesWithCountsDesc.length) { - const [smallestWorker, nextSmallestWorker] = workers.sort(ascending((w) => w.moduleCount)); + const [smallestWorker, nextSmallestWorker] = workers.sort(ascending((w) => w.workUnits)); - while (!nextSmallestWorker || smallestWorker.moduleCount <= nextSmallestWorker.moduleCount) { + while (!nextSmallestWorker || smallestWorker.workUnits <= nextSmallestWorker.workUnits) { const bundle = bundlesWithCountsDesc.shift(); if (!bundle) { @@ -104,7 +103,7 @@ export function assignBundlesToWorkers(bundles: Bundle[], maxWorkerCount: number * assign bundles without module counts to workers round-robin * starting with the smallest workers */ - workers.sort(ascending((w) => w.moduleCount)); + workers.sort(ascending((w) => w.workUnits)); while (bundlesWithoutModuleCounts.length) { for (const worker of workers) { const bundle = bundlesWithoutModuleCounts.shift(); diff --git a/packages/kbn-optimizer/src/optimizer/filter_by_id.test.ts b/packages/kbn-optimizer/src/optimizer/filter_by_id.test.ts new file mode 100644 index 00000000000000..3e848fe616b490 --- /dev/null +++ b/packages/kbn-optimizer/src/optimizer/filter_by_id.test.ts @@ -0,0 +1,72 @@ +/* + * 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 { filterById, HasId } from './filter_by_id'; + +const bundles: HasId[] = [ + { id: 'foo' }, + { id: 'bar' }, + { id: 'abc' }, + { id: 'abcd' }, + { id: 'abcde' }, + { id: 'example_a' }, +]; + +const print = (result: HasId[]) => + result + .map((b) => b.id) + .sort((a, b) => a.localeCompare(b)) + .join(', '); + +it('[] matches everything', () => { + expect(print(filterById([], bundles))).toMatchInlineSnapshot( + `"abc, abcd, abcde, bar, example_a, foo"` + ); +}); + +it('* matches everything', () => { + expect(print(filterById(['*'], bundles))).toMatchInlineSnapshot( + `"abc, abcd, abcde, bar, example_a, foo"` + ); +}); + +it('combines mutliple filters to select any bundle which is matched', () => { + expect(print(filterById(['foo', 'bar'], bundles))).toMatchInlineSnapshot(`"bar, foo"`); + expect(print(filterById(['bar', 'abc*'], bundles))).toMatchInlineSnapshot( + `"abc, abcd, abcde, bar"` + ); +}); + +it('matches everything if any filter is *', () => { + expect(print(filterById(['*', '!abc*'], bundles))).toMatchInlineSnapshot( + `"abc, abcd, abcde, bar, example_a, foo"` + ); +}); + +it('only matches bundles which are matched by an entire single filter', () => { + expect(print(filterById(['*,!abc*'], bundles))).toMatchInlineSnapshot(`"bar, example_a, foo"`); +}); + +it('handles purely positive filters', () => { + expect(print(filterById(['abc*'], bundles))).toMatchInlineSnapshot(`"abc, abcd, abcde"`); +}); + +it('handles purely negative filters', () => { + expect(print(filterById(['!abc*'], bundles))).toMatchInlineSnapshot(`"bar, example_a, foo"`); +}); diff --git a/packages/kbn-optimizer/src/optimizer/filter_by_id.ts b/packages/kbn-optimizer/src/optimizer/filter_by_id.ts new file mode 100644 index 00000000000000..ccf61a9efc880c --- /dev/null +++ b/packages/kbn-optimizer/src/optimizer/filter_by_id.ts @@ -0,0 +1,48 @@ +/* + * 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 interface HasId { + id: string; +} + +function parseFilter(filter: string) { + const positive: RegExp[] = []; + const negative: RegExp[] = []; + + for (const segment of filter.split(',')) { + let trimmed = segment.trim(); + let list = positive; + + if (trimmed.startsWith('!')) { + trimmed = trimmed.slice(1); + list = negative; + } + + list.push(new RegExp(`^${trimmed.split('*').join('.*')}$`)); + } + + return (bundle: HasId) => + (!positive.length || positive.some((p) => p.test(bundle.id))) && + (!negative.length || !negative.some((p) => p.test(bundle.id))); +} + +export function filterById(filterStrings: string[], bundles: T[]) { + const filters = filterStrings.map(parseFilter); + return bundles.filter((b) => !filters.length || filters.some((f) => f(b))); +} diff --git a/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.test.ts b/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.test.ts index bbd3ddc11f448d..a70cfc759dd55b 100644 --- a/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.test.ts +++ b/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.test.ts @@ -32,18 +32,21 @@ it('returns a bundle for core and each plugin', () => { id: 'foo', isUiPlugin: true, extraPublicDirs: [], + manifestPath: '/repo/plugins/foo/kibana.json', }, { directory: '/repo/plugins/bar', id: 'bar', isUiPlugin: false, extraPublicDirs: [], + manifestPath: '/repo/plugins/bar/kibana.json', }, { directory: '/outside/of/repo/plugins/baz', id: 'baz', isUiPlugin: true, extraPublicDirs: [], + manifestPath: '/outside/of/repo/plugins/baz/kibana.json', }, ], '/repo' @@ -53,6 +56,7 @@ it('returns a bundle for core and each plugin', () => { Object { "contextDir": /plugins/foo, "id": "foo", + "manifestPath": /plugins/foo/kibana.json, "outputDir": /plugins/foo/target/public, "publicDirNames": Array [ "public", @@ -63,6 +67,7 @@ it('returns a bundle for core and each plugin', () => { Object { "contextDir": "/outside/of/repo/plugins/baz", "id": "baz", + "manifestPath": "/outside/of/repo/plugins/baz/kibana.json", "outputDir": "/outside/of/repo/plugins/baz/target/public", "publicDirNames": Array [ "public", diff --git a/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.ts b/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.ts index 2635289088725f..04ab992addeec1 100644 --- a/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.ts +++ b/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.ts @@ -35,6 +35,7 @@ export function getPluginBundles(plugins: KibanaPlatformPlugin[], repoRoot: stri sourceRoot: repoRoot, contextDir: p.directory, outputDir: Path.resolve(p.directory, 'target/public'), + manifestPath: p.manifestPath, }) ); } diff --git a/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.test.ts b/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.test.ts index f7b457ca42c6dc..06fffc953f58bb 100644 --- a/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.test.ts +++ b/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.test.ts @@ -40,24 +40,28 @@ it('parses kibana.json files of plugins found in pluginDirs', () => { "extraPublicDirs": Array [], "id": "bar", "isUiPlugin": true, + "manifestPath": /packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/kibana.json, }, Object { "directory": /packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo, "extraPublicDirs": Array [], "id": "foo", "isUiPlugin": true, + "manifestPath": /packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo/kibana.json, }, Object { "directory": /packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/nested/baz, "extraPublicDirs": Array [], "id": "baz", "isUiPlugin": false, + "manifestPath": /packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/nested/baz/kibana.json, }, Object { "directory": /packages/kbn-optimizer/src/__fixtures__/mock_repo/test_plugins/test_baz, "extraPublicDirs": Array [], "id": "test_baz", "isUiPlugin": false, + "manifestPath": /packages/kbn-optimizer/src/__fixtures__/mock_repo/test_plugins/test_baz/kibana.json, }, ] `); diff --git a/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.ts b/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.ts index 83637691004f40..b489c53be47b96 100644 --- a/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.ts +++ b/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.ts @@ -24,6 +24,7 @@ import loadJsonFile from 'load-json-file'; export interface KibanaPlatformPlugin { readonly directory: string; + readonly manifestPath: string; readonly id: string; readonly isUiPlugin: boolean; readonly extraPublicDirs: string[]; @@ -92,6 +93,7 @@ function readKibanaPlatformPlugin(manifestPath: string): KibanaPlatformPlugin { return { directory: Path.dirname(manifestPath), + manifestPath, id: manifest.id, isUiPlugin: !!manifest.ui, extraPublicDirs: extraPublicDirs || [], diff --git a/packages/kbn-optimizer/src/optimizer/optimizer_config.test.ts b/packages/kbn-optimizer/src/optimizer/optimizer_config.test.ts index 5b46d67479fd58..f97646e2bbbd3e 100644 --- a/packages/kbn-optimizer/src/optimizer/optimizer_config.test.ts +++ b/packages/kbn-optimizer/src/optimizer/optimizer_config.test.ts @@ -21,6 +21,7 @@ jest.mock('./assign_bundles_to_workers.ts'); jest.mock('./kibana_platform_plugins.ts'); jest.mock('./get_plugin_bundles.ts'); jest.mock('../common/theme_tags.ts'); +jest.mock('./filter_by_id.ts'); import Path from 'path'; import Os from 'os'; @@ -113,6 +114,7 @@ describe('OptimizerConfig::parseOptions()', () => { Object { "cache": true, "dist": false, + "filters": Array [], "includeCoreBundle": false, "inspectWorkers": false, "maxWorkerCount": 2, @@ -139,6 +141,7 @@ describe('OptimizerConfig::parseOptions()', () => { Object { "cache": false, "dist": false, + "filters": Array [], "includeCoreBundle": false, "inspectWorkers": false, "maxWorkerCount": 2, @@ -165,6 +168,7 @@ describe('OptimizerConfig::parseOptions()', () => { Object { "cache": true, "dist": false, + "filters": Array [], "includeCoreBundle": false, "inspectWorkers": false, "maxWorkerCount": 2, @@ -193,6 +197,7 @@ describe('OptimizerConfig::parseOptions()', () => { Object { "cache": true, "dist": false, + "filters": Array [], "includeCoreBundle": false, "inspectWorkers": false, "maxWorkerCount": 2, @@ -218,6 +223,7 @@ describe('OptimizerConfig::parseOptions()', () => { Object { "cache": true, "dist": false, + "filters": Array [], "includeCoreBundle": false, "inspectWorkers": false, "maxWorkerCount": 2, @@ -243,6 +249,7 @@ describe('OptimizerConfig::parseOptions()', () => { Object { "cache": true, "dist": false, + "filters": Array [], "includeCoreBundle": false, "inspectWorkers": false, "maxWorkerCount": 100, @@ -265,6 +272,7 @@ describe('OptimizerConfig::parseOptions()', () => { Object { "cache": false, "dist": false, + "filters": Array [], "includeCoreBundle": false, "inspectWorkers": false, "maxWorkerCount": 100, @@ -287,6 +295,7 @@ describe('OptimizerConfig::parseOptions()', () => { Object { "cache": false, "dist": false, + "filters": Array [], "includeCoreBundle": false, "inspectWorkers": false, "maxWorkerCount": 100, @@ -310,6 +319,7 @@ describe('OptimizerConfig::parseOptions()', () => { Object { "cache": false, "dist": false, + "filters": Array [], "includeCoreBundle": false, "inspectWorkers": false, "maxWorkerCount": 100, @@ -333,6 +343,7 @@ describe('OptimizerConfig::parseOptions()', () => { Object { "cache": true, "dist": false, + "filters": Array [], "includeCoreBundle": false, "inspectWorkers": false, "maxWorkerCount": 100, @@ -358,6 +369,7 @@ describe('OptimizerConfig::create()', () => { const findKibanaPlatformPlugins: jest.Mock = jest.requireMock('./kibana_platform_plugins.ts') .findKibanaPlatformPlugins; const getPluginBundles: jest.Mock = jest.requireMock('./get_plugin_bundles.ts').getPluginBundles; + const filterById: jest.Mock = jest.requireMock('./filter_by_id.ts').filterById; beforeEach(() => { if ('mock' in OptimizerConfig.parseOptions) { @@ -370,6 +382,7 @@ describe('OptimizerConfig::create()', () => { ]); findKibanaPlatformPlugins.mockReturnValue(Symbol('new platform plugins')); getPluginBundles.mockReturnValue([Symbol('bundle1'), Symbol('bundle2')]); + filterById.mockReturnValue(Symbol('filtered bundles')); jest.spyOn(OptimizerConfig, 'parseOptions').mockImplementation((): any => ({ cache: Symbol('parsed cache'), @@ -382,6 +395,7 @@ describe('OptimizerConfig::create()', () => { themeTags: Symbol('theme tags'), inspectWorkers: Symbol('parsed inspect workers'), profileWebpack: Symbol('parsed profile webpack'), + filters: [], })); }); @@ -392,10 +406,7 @@ describe('OptimizerConfig::create()', () => { expect(config).toMatchInlineSnapshot(` OptimizerConfig { - "bundles": Array [ - Symbol(bundle1), - Symbol(bundle2), - ], + "bundles": Symbol(filtered bundles), "cache": Symbol(parsed cache), "dist": Symbol(parsed dist), "inspectWorkers": Symbol(parsed inspect workers), @@ -431,6 +442,32 @@ describe('OptimizerConfig::create()', () => { } `); + expect(filterById.mock).toMatchInlineSnapshot(` + Object { + "calls": Array [ + Array [ + Array [], + Array [ + Symbol(bundle1), + Symbol(bundle2), + ], + ], + ], + "instances": Array [ + [Window], + ], + "invocationCallOrder": Array [ + 23, + ], + "results": Array [ + Object { + "type": "return", + "value": Symbol(filtered bundles), + }, + ], + } + `); + expect(getPluginBundles.mock).toMatchInlineSnapshot(` Object { "calls": Array [ diff --git a/packages/kbn-optimizer/src/optimizer/optimizer_config.ts b/packages/kbn-optimizer/src/optimizer/optimizer_config.ts index 7757004139d0d4..0e588ab36238b3 100644 --- a/packages/kbn-optimizer/src/optimizer/optimizer_config.ts +++ b/packages/kbn-optimizer/src/optimizer/optimizer_config.ts @@ -31,6 +31,7 @@ import { import { findKibanaPlatformPlugins, KibanaPlatformPlugin } from './kibana_platform_plugins'; import { getPluginBundles } from './get_plugin_bundles'; +import { filterById } from './filter_by_id'; function pickMaxWorkerCount(dist: boolean) { // don't break if cpus() returns nothing, or an empty array @@ -77,6 +78,18 @@ interface Options { pluginScanDirs?: string[]; /** absolute paths that should be added to the default scan dirs */ extraPluginScanDirs?: string[]; + /** + * array of comma separated patterns that will be matched against bundle ids. + * bundles will only be built if they match one of the specified patterns. + * `*` can exist anywhere in each pattern and will match anything, `!` inverts the pattern + * + * examples: + * --filter foo --filter bar # [foo, bar], excludes [foobar] + * --filter foo,bar # [foo, bar], excludes [foobar] + * --filter foo* # [foo, foobar], excludes [bar] + * --filter f*r # [foobar], excludes [foo, bar] + */ + filter?: string[]; /** flag that causes the core bundle to be built along with plugins */ includeCoreBundle?: boolean; @@ -103,6 +116,7 @@ interface ParsedOptions { dist: boolean; pluginPaths: string[]; pluginScanDirs: string[]; + filters: string[]; inspectWorkers: boolean; includeCoreBundle: boolean; themeTags: ThemeTags; @@ -118,6 +132,7 @@ export class OptimizerConfig { const inspectWorkers = !!options.inspectWorkers; const cache = options.cache !== false && !process.env.KBN_OPTIMIZER_NO_CACHE; const includeCoreBundle = !!options.includeCoreBundle; + const filters = options.filter || []; const repoRoot = options.repoRoot; if (!Path.isAbsolute(repoRoot)) { @@ -172,6 +187,7 @@ export class OptimizerConfig { cache, pluginScanDirs, pluginPaths, + filters, inspectWorkers, includeCoreBundle, themeTags, @@ -198,7 +214,7 @@ export class OptimizerConfig { ]; return new OptimizerConfig( - bundles, + filterById(options.filters, bundles), options.cache, options.watch, options.inspectWorkers, diff --git a/packages/kbn-optimizer/src/worker/bundle_ref_module.ts b/packages/kbn-optimizer/src/worker/bundle_ref_module.ts index cde25564cf5282..563b4ecb4bc37b 100644 --- a/packages/kbn-optimizer/src/worker/bundle_ref_module.ts +++ b/packages/kbn-optimizer/src/worker/bundle_ref_module.ts @@ -10,6 +10,7 @@ // @ts-ignore not typed by @types/webpack import Module from 'webpack/lib/Module'; +import { BundleRef } from '../common'; export class BundleRefModule extends Module { public built = false; @@ -17,12 +18,12 @@ export class BundleRefModule extends Module { public buildInfo?: any; public exportsArgument = '__webpack_exports__'; - constructor(public readonly exportId: string) { + constructor(public readonly ref: BundleRef) { super('kbn/bundleRef', null); } libIdent() { - return this.exportId; + return this.ref.exportId; } chunkCondition(chunk: any) { @@ -30,7 +31,7 @@ export class BundleRefModule extends Module { } identifier() { - return '@kbn/bundleRef ' + JSON.stringify(this.exportId); + return '@kbn/bundleRef ' + JSON.stringify(this.ref.exportId); } readableIdentifier() { @@ -51,7 +52,7 @@ export class BundleRefModule extends Module { source() { return ` __webpack_require__.r(__webpack_exports__); - var ns = __kbnBundles__.get('${this.exportId}'); + var ns = __kbnBundles__.get('${this.ref.exportId}'); Object.defineProperties(__webpack_exports__, Object.getOwnPropertyDescriptors(ns)) `; } diff --git a/packages/kbn-optimizer/src/worker/bundle_refs_plugin.ts b/packages/kbn-optimizer/src/worker/bundle_refs_plugin.ts index 9c4d5ed7f8a983..5396d11726f7a7 100644 --- a/packages/kbn-optimizer/src/worker/bundle_refs_plugin.ts +++ b/packages/kbn-optimizer/src/worker/bundle_refs_plugin.ts @@ -44,6 +44,7 @@ export class BundleRefsPlugin { private readonly resolvedRefEntryCache = new Map>(); private readonly resolvedRequestCache = new Map>(); private readonly ignorePrefix = Path.resolve(this.bundle.contextDir) + Path.sep; + private allowedBundleIds = new Set(); constructor(private readonly bundle: Bundle, private readonly bundleRefs: BundleRefs) {} @@ -81,6 +82,45 @@ export class BundleRefsPlugin { } ); }); + + compiler.hooks.compilation.tap('BundleRefsPlugin/getRequiredBundles', (compilation) => { + this.allowedBundleIds.clear(); + + const manifestPath = this.bundle.manifestPath; + if (!manifestPath) { + return; + } + + const deps = this.bundle.readBundleDeps(); + for (const ref of this.bundleRefs.forBundleIds([...deps.explicit, ...deps.implicit])) { + this.allowedBundleIds.add(ref.bundleId); + } + + compilation.hooks.additionalAssets.tap('BundleRefsPlugin/watchManifest', () => { + compilation.fileDependencies.add(manifestPath); + }); + + compilation.hooks.finishModules.tapPromise( + 'BundleRefsPlugin/finishModules', + async (modules) => { + const usedBundleIds = (modules as any[]) + .filter((m: any): m is BundleRefModule => m instanceof BundleRefModule) + .map((m) => m.ref.bundleId); + + const unusedBundleIds = deps.explicit + .filter((id) => !usedBundleIds.includes(id)) + .join(', '); + + if (unusedBundleIds) { + const error = new Error( + `Bundle for [${this.bundle.id}] lists [${unusedBundleIds}] as a required bundle, but does not use it. Please remove it.` + ); + (error as any).file = manifestPath; + compilation.errors.push(error); + } + } + ); + }); } private cachedResolveRefEntry(ref: BundleRef) { @@ -170,21 +210,29 @@ export class BundleRefsPlugin { return; } - const eligibleRefs = this.bundleRefs.filterByContextPrefix(this.bundle, resolved); - if (!eligibleRefs.length) { + const possibleRefs = this.bundleRefs.filterByContextPrefix(this.bundle, resolved); + if (!possibleRefs.length) { // import doesn't match a bundle context return; } - for (const ref of eligibleRefs) { + for (const ref of possibleRefs) { const resolvedEntry = await this.cachedResolveRefEntry(ref); - if (resolved === resolvedEntry) { - return new BundleRefModule(ref.exportId); + if (resolved !== resolvedEntry) { + continue; } + + if (!this.allowedBundleIds.has(ref.bundleId)) { + throw new Error( + `import [${request}] references a public export of the [${ref.bundleId}] bundle, but that bundle is not in the "requiredPlugins" or "requiredBundles" list in the plugin manifest [${this.bundle.manifestPath}]` + ); + } + + return new BundleRefModule(ref); } - const bundleId = Array.from(new Set(eligibleRefs.map((r) => r.bundleId))).join(', '); - const publicDir = eligibleRefs.map((r) => r.entry).join(', '); + const bundleId = Array.from(new Set(possibleRefs.map((r) => r.bundleId))).join(', '); + const publicDir = possibleRefs.map((r) => r.entry).join(', '); throw new Error( `import [${request}] references a non-public export of the [${bundleId}] bundle and must point to one of the public directories: [${publicDir}]` ); diff --git a/packages/kbn-optimizer/src/worker/run_compilers.ts b/packages/kbn-optimizer/src/worker/run_compilers.ts index ca7673748bde91..c7be943d65a489 100644 --- a/packages/kbn-optimizer/src/worker/run_compilers.ts +++ b/packages/kbn-optimizer/src/worker/run_compilers.ts @@ -50,6 +50,15 @@ import { const PLUGIN_NAME = '@kbn/optimizer'; +/** + * sass-loader creates about a 40% overhead on the overall optimizer runtime, and + * so this constant is used to indicate to assignBundlesToWorkers() that there is + * extra work done in a bundle that has a lot of scss imports. The value is + * arbitrary and just intended to weigh the bundles so that they are distributed + * across mulitple workers on machines with lots of cores. + */ +const EXTRA_SCSS_WORK_UNITS = 100; + /** * Create an Observable for a specific child compiler + bundle */ @@ -102,6 +111,11 @@ const observeCompiler = ( const bundleRefExportIds: string[] = []; const referencedFiles = new Set(); let normalModuleCount = 0; + let workUnits = stats.compilation.fileDependencies.size; + + if (bundle.manifestPath) { + referencedFiles.add(bundle.manifestPath); + } for (const module of stats.compilation.modules) { if (isNormalModule(module)) { @@ -111,6 +125,15 @@ const observeCompiler = ( if (!parsedPath.dirs.includes('node_modules')) { referencedFiles.add(path); + + if (path.endsWith('.scss')) { + workUnits += EXTRA_SCSS_WORK_UNITS; + + for (const depPath of module.buildInfo.fileDependencies) { + referencedFiles.add(depPath); + } + } + continue; } @@ -127,7 +150,7 @@ const observeCompiler = ( } if (module instanceof BundleRefModule) { - bundleRefExportIds.push(module.exportId); + bundleRefExportIds.push(module.ref.exportId); continue; } @@ -158,6 +181,7 @@ const observeCompiler = ( optimizerCacheKey: workerConfig.optimizerCacheKey, cacheKey: bundle.createCacheKey(files, mtimes), moduleCount: normalModuleCount, + workUnits, files, }); diff --git a/src/core/public/plugins/plugin.test.ts b/src/core/public/plugins/plugin.test.ts index 3f77161f8c34d9..7331157951dec0 100644 --- a/src/core/public/plugins/plugin.test.ts +++ b/src/core/public/plugins/plugin.test.ts @@ -32,6 +32,7 @@ function createManifest( configPath: ['path'], requiredPlugins: required, optionalPlugins: optional, + requiredBundles: [], } as DiscoveredPlugin; } diff --git a/src/core/public/plugins/plugins_service.test.ts b/src/core/public/plugins/plugins_service.test.ts index 7dc5f3655fca03..28cbfce3fd6510 100644 --- a/src/core/public/plugins/plugins_service.test.ts +++ b/src/core/public/plugins/plugins_service.test.ts @@ -73,6 +73,7 @@ function createManifest( configPath: ['path'], requiredPlugins: required, optionalPlugins: optional, + requiredBundles: [], }; } diff --git a/src/core/server/legacy/legacy_service.test.ts b/src/core/server/legacy/legacy_service.test.ts index ae4cf612e92ce7..f8f04c59766b38 100644 --- a/src/core/server/legacy/legacy_service.test.ts +++ b/src/core/server/legacy/legacy_service.test.ts @@ -109,6 +109,7 @@ beforeEach(() => { [ 'plugin-id', { + requiredBundles: [], publicTargetDir: 'path/to/target/public', publicAssetsDir: '/plugins/name/assets/', }, diff --git a/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts b/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts index 5ffdef88104c83..64d1256be2f30f 100644 --- a/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts +++ b/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts @@ -302,6 +302,7 @@ test('set defaults for all missing optional fields', async () => { kibanaVersion: '7.0.0', optionalPlugins: [], requiredPlugins: [], + requiredBundles: [], server: true, ui: false, }); @@ -331,6 +332,7 @@ test('return all set optional fields as they are in manifest', async () => { version: 'some-version', kibanaVersion: '7.0.0', optionalPlugins: ['some-optional-plugin'], + requiredBundles: [], requiredPlugins: ['some-required-plugin', 'some-required-plugin-2'], server: false, ui: true, @@ -361,6 +363,7 @@ test('return manifest when plugin expected Kibana version matches actual version kibanaVersion: '7.0.0-alpha2', optionalPlugins: [], requiredPlugins: ['some-required-plugin'], + requiredBundles: [], server: true, ui: false, }); @@ -390,6 +393,7 @@ test('return manifest when plugin expected Kibana version is `kibana`', async () kibanaVersion: 'kibana', optionalPlugins: [], requiredPlugins: ['some-required-plugin'], + requiredBundles: [], server: true, ui: true, }); diff --git a/src/core/server/plugins/discovery/plugin_manifest_parser.ts b/src/core/server/plugins/discovery/plugin_manifest_parser.ts index f2c3a29eca0ac6..0d33e266c37dbd 100644 --- a/src/core/server/plugins/discovery/plugin_manifest_parser.ts +++ b/src/core/server/plugins/discovery/plugin_manifest_parser.ts @@ -58,6 +58,7 @@ const KNOWN_MANIFEST_FIELDS = (() => { ui: true, server: true, extraPublicDirs: true, + requiredBundles: true, }; return new Set(Object.keys(manifestFields)); @@ -191,6 +192,7 @@ export async function parseManifest( configPath: manifest.configPath || snakeCase(manifest.id), requiredPlugins: Array.isArray(manifest.requiredPlugins) ? manifest.requiredPlugins : [], optionalPlugins: Array.isArray(manifest.optionalPlugins) ? manifest.optionalPlugins : [], + requiredBundles: Array.isArray(manifest.requiredBundles) ? manifest.requiredBundles : [], ui: includesUiPlugin, server: includesServerPlugin, extraPublicDirs: manifest.extraPublicDirs, diff --git a/src/core/server/plugins/integration_tests/plugins_service.test.ts b/src/core/server/plugins/integration_tests/plugins_service.test.ts index e676c789449caf..49c129d0ae67d7 100644 --- a/src/core/server/plugins/integration_tests/plugins_service.test.ts +++ b/src/core/server/plugins/integration_tests/plugins_service.test.ts @@ -43,6 +43,7 @@ describe('PluginsService', () => { disabled = false, version = 'some-version', requiredPlugins = [], + requiredBundles = [], optionalPlugins = [], kibanaVersion = '7.0.0', configPath = [path], @@ -53,6 +54,7 @@ describe('PluginsService', () => { disabled?: boolean; version?: string; requiredPlugins?: string[]; + requiredBundles?: string[]; optionalPlugins?: string[]; kibanaVersion?: string; configPath?: ConfigPath; @@ -68,6 +70,7 @@ describe('PluginsService', () => { configPath: `${configPath}${disabled ? '-disabled' : ''}`, kibanaVersion, requiredPlugins, + requiredBundles, optionalPlugins, server, ui, diff --git a/src/core/server/plugins/plugin.test.ts b/src/core/server/plugins/plugin.test.ts index ec0a3986b48775..4f26686e1f5e0c 100644 --- a/src/core/server/plugins/plugin.test.ts +++ b/src/core/server/plugins/plugin.test.ts @@ -54,6 +54,7 @@ function createPluginManifest(manifestProps: Partial = {}): Plug kibanaVersion: '7.0.0', requiredPlugins: ['some-required-dep'], optionalPlugins: ['some-optional-dep'], + requiredBundles: [], server: true, ui: true, ...manifestProps, diff --git a/src/core/server/plugins/plugin.ts b/src/core/server/plugins/plugin.ts index 2e5881c6518439..8bd98408556546 100644 --- a/src/core/server/plugins/plugin.ts +++ b/src/core/server/plugins/plugin.ts @@ -53,6 +53,7 @@ export class PluginWrapper< public readonly configPath: PluginManifest['configPath']; public readonly requiredPlugins: PluginManifest['requiredPlugins']; public readonly optionalPlugins: PluginManifest['optionalPlugins']; + public readonly requiredBundles: PluginManifest['requiredBundles']; public readonly includesServerPlugin: PluginManifest['server']; public readonly includesUiPlugin: PluginManifest['ui']; @@ -81,6 +82,7 @@ export class PluginWrapper< this.configPath = params.manifest.configPath; this.requiredPlugins = params.manifest.requiredPlugins; this.optionalPlugins = params.manifest.optionalPlugins; + this.requiredBundles = params.manifest.requiredBundles; this.includesServerPlugin = params.manifest.server; this.includesUiPlugin = params.manifest.ui; } diff --git a/src/core/server/plugins/plugin_context.test.ts b/src/core/server/plugins/plugin_context.test.ts index 69b354661abc9d..ebd068caadfb9f 100644 --- a/src/core/server/plugins/plugin_context.test.ts +++ b/src/core/server/plugins/plugin_context.test.ts @@ -43,6 +43,7 @@ function createPluginManifest(manifestProps: Partial = {}): Plug configPath: 'path', kibanaVersion: '7.0.0', requiredPlugins: ['some-required-dep'], + requiredBundles: [], optionalPlugins: ['some-optional-dep'], server: true, ui: true, diff --git a/src/core/server/plugins/plugins_service.test.ts b/src/core/server/plugins/plugins_service.test.ts index 46fd2b00c23044..aa77335991e2cd 100644 --- a/src/core/server/plugins/plugins_service.test.ts +++ b/src/core/server/plugins/plugins_service.test.ts @@ -64,6 +64,7 @@ const createPlugin = ( disabled = false, version = 'some-version', requiredPlugins = [], + requiredBundles = [], optionalPlugins = [], kibanaVersion = '7.0.0', configPath = [path], @@ -74,6 +75,7 @@ const createPlugin = ( disabled?: boolean; version?: string; requiredPlugins?: string[]; + requiredBundles?: string[]; optionalPlugins?: string[]; kibanaVersion?: string; configPath?: ConfigPath; @@ -89,6 +91,7 @@ const createPlugin = ( configPath: `${configPath}${disabled ? '-disabled' : ''}`, kibanaVersion, requiredPlugins, + requiredBundles, optionalPlugins, server, ui, @@ -460,6 +463,7 @@ describe('PluginsService', () => { id: plugin.name, configPath: plugin.manifest.configPath, requiredPlugins: [], + requiredBundles: [], optionalPlugins: [], }, ]; @@ -563,10 +567,12 @@ describe('PluginsService', () => { "plugin-1" => Object { "publicAssetsDir": /path-1/public/assets, "publicTargetDir": /path-1/target/public, + "requiredBundles": Array [], }, "plugin-2" => Object { "publicAssetsDir": /path-2/public/assets, "publicTargetDir": /path-2/target/public, + "requiredBundles": Array [], }, } `); diff --git a/src/core/server/plugins/plugins_service.ts b/src/core/server/plugins/plugins_service.ts index 5d1261e697bc06..06de48a215881a 100644 --- a/src/core/server/plugins/plugins_service.ts +++ b/src/core/server/plugins/plugins_service.ts @@ -228,6 +228,7 @@ export class PluginsService implements CoreService uiPluginNames.includes(p) ), + requiredBundles: plugin.manifest.requiredBundles, }, ]; }) diff --git a/src/core/server/plugins/types.ts b/src/core/server/plugins/types.ts index 9e86ee22c607bf..9695c9171a7714 100644 --- a/src/core/server/plugins/types.ts +++ b/src/core/server/plugins/types.ts @@ -136,6 +136,18 @@ export interface PluginManifest { */ readonly requiredPlugins: readonly PluginName[]; + /** + * List of plugin ids that this plugin's UI code imports modules from that are + * not in `requiredPlugins`. + * + * @remarks + * The plugins listed here will be loaded in the browser, even if the plugin is + * disabled. Required by `@kbn/optimizer` to support cross-plugin imports. + * "core" and plugins already listed in `requiredPlugins` do not need to be + * duplicated here. + */ + readonly requiredBundles: readonly string[]; + /** * An optional list of the other plugins that if installed and enabled **may be** * leveraged by this plugin for some additional functionality but otherwise are @@ -191,12 +203,28 @@ export interface DiscoveredPlugin { * not required for this plugin to work properly. */ readonly optionalPlugins: readonly PluginName[]; + + /** + * List of plugin ids that this plugin's UI code imports modules from that are + * not in `requiredPlugins`. + * + * @remarks + * The plugins listed here will be loaded in the browser, even if the plugin is + * disabled. Required by `@kbn/optimizer` to support cross-plugin imports. + * "core" and plugins already listed in `requiredPlugins` do not need to be + * duplicated here. + */ + readonly requiredBundles: readonly PluginName[]; } /** * @internal */ export interface InternalPluginInfo { + /** + * Bundles that must be loaded for this plugoin + */ + readonly requiredBundles: readonly string[]; /** * Path to the target/public directory of the plugin which should be * served diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 95912c3af63e51..3d3e1905577d91 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -637,6 +637,7 @@ export interface DiscoveredPlugin { readonly configPath: ConfigPath; readonly id: PluginName; readonly optionalPlugins: readonly PluginName[]; + readonly requiredBundles: readonly PluginName[]; readonly requiredPlugins: readonly PluginName[]; } @@ -1684,6 +1685,7 @@ export interface PluginManifest { readonly id: PluginName; readonly kibanaVersion: string; readonly optionalPlugins: readonly PluginName[]; + readonly requiredBundles: readonly string[]; readonly requiredPlugins: readonly PluginName[]; readonly server: boolean; readonly ui: boolean; @@ -2706,8 +2708,8 @@ export const validBodyOutput: readonly ["data", "stream"]; // src/core/server/legacy/types.ts:165:3 - (ae-forgotten-export) The symbol "LegacyNavLinkSpec" needs to be exported by the entry point index.d.ts // src/core/server/legacy/types.ts:166:3 - (ae-forgotten-export) The symbol "LegacyAppSpec" needs to be exported by the entry point index.d.ts // src/core/server/legacy/types.ts:167:16 - (ae-forgotten-export) The symbol "LegacyPluginSpec" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/types.ts:238:3 - (ae-forgotten-export) The symbol "KibanaConfigType" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/types.ts:238:3 - (ae-forgotten-export) The symbol "SharedGlobalConfigKeys" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/types.ts:240:3 - (ae-forgotten-export) The symbol "PathConfigType" needs to be exported by the entry point index.d.ts +// src/core/server/plugins/types.ts:266:3 - (ae-forgotten-export) The symbol "KibanaConfigType" needs to be exported by the entry point index.d.ts +// src/core/server/plugins/types.ts:266:3 - (ae-forgotten-export) The symbol "SharedGlobalConfigKeys" needs to be exported by the entry point index.d.ts +// src/core/server/plugins/types.ts:268:3 - (ae-forgotten-export) The symbol "PathConfigType" needs to be exported by the entry point index.d.ts ``` diff --git a/src/legacy/ui/ui_render/ui_render_mixin.js b/src/legacy/ui/ui_render/ui_render_mixin.js index b4b18e086e809f..168dddf0253d9e 100644 --- a/src/legacy/ui/ui_render/ui_render_mixin.js +++ b/src/legacy/ui/ui_render/ui_render_mixin.js @@ -150,7 +150,23 @@ export function uiRenderMixin(kbnServer, server, config) { ]), ]; - const kpPluginIds = Array.from(kbnServer.newPlatform.__internals.uiPlugins.public.keys()); + const kpUiPlugins = kbnServer.newPlatform.__internals.uiPlugins; + const kpPluginPublicPaths = new Map(); + const kpPluginBundlePaths = new Set(); + + // recursively iterate over the kpUiPlugin ids and their required bundles + // to populate kpPluginPublicPaths and kpPluginBundlePaths + (function readKpPlugins(ids) { + for (const id of ids) { + if (kpPluginPublicPaths.has(id)) { + continue; + } + + kpPluginPublicPaths.set(id, `${regularBundlePath}/plugin/${id}/`); + kpPluginBundlePaths.add(`${regularBundlePath}/plugin/${id}/${id}.plugin.js`); + readKpPlugins(kpUiPlugins.internal.get(id).requiredBundles); + } + })(kpUiPlugins.public.keys()); const jsDependencyPaths = [ ...UiSharedDeps.jsDepFilenames.map( @@ -160,9 +176,7 @@ export function uiRenderMixin(kbnServer, server, config) { ...(isCore ? [] : [`${dllBundlePath}/vendors_runtime.bundle.dll.js`, ...dllJsChunks]), `${regularBundlePath}/core/core.entry.js`, - ...kpPluginIds.map( - (pluginId) => `${regularBundlePath}/plugin/${pluginId}/${pluginId}.plugin.js` - ), + ...kpPluginBundlePaths, ]; // These paths should align with the bundle routes configured in @@ -170,13 +184,7 @@ export function uiRenderMixin(kbnServer, server, config) { const publicPathMap = JSON.stringify({ core: `${regularBundlePath}/core/`, 'kbn-ui-shared-deps': `${regularBundlePath}/kbn-ui-shared-deps/`, - ...kpPluginIds.reduce( - (acc, pluginId) => ({ - ...acc, - [pluginId]: `${regularBundlePath}/plugin/${pluginId}/`, - }), - {} - ), + ...Object.fromEntries(kpPluginPublicPaths), }); const bootstrap = new AppBootstrap({ diff --git a/src/plugins/advanced_settings/kibana.json b/src/plugins/advanced_settings/kibana.json index e6ca6e797ba450..8cf9b9c656d8f2 100644 --- a/src/plugins/advanced_settings/kibana.json +++ b/src/plugins/advanced_settings/kibana.json @@ -3,5 +3,6 @@ "version": "kibana", "server": true, "ui": true, - "requiredPlugins": ["management"] + "requiredPlugins": ["management"], + "requiredBundles": ["kibanaReact"] } diff --git a/src/plugins/bfetch/kibana.json b/src/plugins/bfetch/kibana.json index 462d2f4b8bb7d9..9f9f2176af671f 100644 --- a/src/plugins/bfetch/kibana.json +++ b/src/plugins/bfetch/kibana.json @@ -2,5 +2,6 @@ "id": "bfetch", "version": "kibana", "server": true, - "ui": true + "ui": true, + "requiredBundles": ["kibanaUtils"] } diff --git a/src/plugins/charts/kibana.json b/src/plugins/charts/kibana.json index 9f4433e7099d8c..c4643d541c31c7 100644 --- a/src/plugins/charts/kibana.json +++ b/src/plugins/charts/kibana.json @@ -2,5 +2,6 @@ "id": "charts", "version": "kibana", "server": true, - "ui": true + "ui": true, + "requiredBundles": ["kibanaUtils", "kibanaReact", "data"] } diff --git a/src/plugins/console/kibana.json b/src/plugins/console/kibana.json index 57de385ba565c7..031aa00eb6613d 100644 --- a/src/plugins/console/kibana.json +++ b/src/plugins/console/kibana.json @@ -4,5 +4,6 @@ "server": true, "ui": true, "requiredPlugins": ["devTools", "home"], - "optionalPlugins": ["usageCollection"] + "optionalPlugins": ["usageCollection"], + "requiredBundles": ["esUiShared", "kibanaReact", "kibanaUtils"] } diff --git a/src/plugins/dashboard/kibana.json b/src/plugins/dashboard/kibana.json index 4cd8f3c7d981f6..1b38c6d124fe1f 100644 --- a/src/plugins/dashboard/kibana.json +++ b/src/plugins/dashboard/kibana.json @@ -12,5 +12,6 @@ ], "optionalPlugins": ["home", "share", "usageCollection"], "server": true, - "ui": true + "ui": true, + "requiredBundles": ["kibanaUtils", "kibanaReact", "home"] } diff --git a/src/plugins/data/kibana.json b/src/plugins/data/kibana.json index 3e5d96a4bc47bf..2ffd0688b134ee 100644 --- a/src/plugins/data/kibana.json +++ b/src/plugins/data/kibana.json @@ -8,5 +8,11 @@ "uiActions" ], "optionalPlugins": ["usageCollection"], - "extraPublicDirs": ["common", "common/utils/abort_utils"] + "extraPublicDirs": ["common", "common/utils/abort_utils"], + "requiredBundles": [ + "kibanaUtils", + "kibanaReact", + "kibanaLegacy", + "inspector" + ] } diff --git a/src/plugins/discover/kibana.json b/src/plugins/discover/kibana.json index 14dd399697b568..041f362bf06230 100644 --- a/src/plugins/discover/kibana.json +++ b/src/plugins/discover/kibana.json @@ -1,7 +1,6 @@ { "id": "discover", "version": "kibana", - "optionalPlugins": ["share"], "server": true, "ui": true, "requiredPlugins": [ @@ -14,5 +13,11 @@ "uiActions", "visualizations" ], - "optionalPlugins": ["home", "share"] + "optionalPlugins": ["home", "share"], + "requiredBundles": [ + "kibanaUtils", + "home", + "savedObjects", + "kibanaReact" + ] } diff --git a/src/plugins/embeddable/kibana.json b/src/plugins/embeddable/kibana.json index 332237d19e2187..3163c4bde4704b 100644 --- a/src/plugins/embeddable/kibana.json +++ b/src/plugins/embeddable/kibana.json @@ -10,5 +10,9 @@ ], "extraPublicDirs": [ "public/lib/test_samples" + ], + "requiredBundles": [ + "savedObjects", + "kibanaReact" ] } diff --git a/src/plugins/es_ui_shared/kibana.json b/src/plugins/es_ui_shared/kibana.json index 980f43ea46a68f..eab7355d66f096 100644 --- a/src/plugins/es_ui_shared/kibana.json +++ b/src/plugins/es_ui_shared/kibana.json @@ -10,5 +10,8 @@ "static/forms/helpers", "static/forms/components", "static/forms/helpers/field_validators/types" + ], + "requiredBundles": [ + "data" ] } diff --git a/src/plugins/expressions/kibana.json b/src/plugins/expressions/kibana.json index 4774c69cc29ffd..5163331088103a 100644 --- a/src/plugins/expressions/kibana.json +++ b/src/plugins/expressions/kibana.json @@ -6,5 +6,10 @@ "requiredPlugins": [ "bfetch" ], - "extraPublicDirs": ["common", "common/fonts"] + "extraPublicDirs": ["common", "common/fonts"], + "requiredBundles": [ + "kibanaUtils", + "inspector", + "data" + ] } diff --git a/src/plugins/home/kibana.json b/src/plugins/home/kibana.json index 1c4b44a946e62b..74bd3625ca9643 100644 --- a/src/plugins/home/kibana.json +++ b/src/plugins/home/kibana.json @@ -4,5 +4,8 @@ "server": true, "ui": true, "requiredPlugins": ["data", "kibanaLegacy"], - "optionalPlugins": ["usageCollection", "telemetry"] + "optionalPlugins": ["usageCollection", "telemetry"], + "requiredBundles": [ + "kibanaReact" + ] } diff --git a/src/plugins/index_pattern_management/kibana.json b/src/plugins/index_pattern_management/kibana.json index 23adef2626a72b..d0ad6a96065c30 100644 --- a/src/plugins/index_pattern_management/kibana.json +++ b/src/plugins/index_pattern_management/kibana.json @@ -3,5 +3,6 @@ "version": "kibana", "server": true, "ui": true, - "requiredPlugins": ["management", "data", "kibanaLegacy"] + "requiredPlugins": ["management", "data", "kibanaLegacy"], + "requiredBundles": ["kibanaReact", "kibanaUtils"] } diff --git a/src/plugins/input_control_vis/kibana.json b/src/plugins/input_control_vis/kibana.json index 4a4ec328c1352b..6928eb19d02e13 100644 --- a/src/plugins/input_control_vis/kibana.json +++ b/src/plugins/input_control_vis/kibana.json @@ -4,5 +4,6 @@ "kibanaVersion": "kibana", "server": true, "ui": true, - "requiredPlugins": ["data", "expressions", "visualizations"] + "requiredPlugins": ["data", "expressions", "visualizations"], + "requiredBundles": ["kibanaReact"] } diff --git a/src/plugins/inspector/kibana.json b/src/plugins/inspector/kibana.json index 99a38d2928df69..90e5d602507283 100644 --- a/src/plugins/inspector/kibana.json +++ b/src/plugins/inspector/kibana.json @@ -3,5 +3,6 @@ "version": "kibana", "server": false, "ui": true, - "extraPublicDirs": ["common", "common/adapters/request"] + "extraPublicDirs": ["common", "common/adapters/request"], + "requiredBundles": ["kibanaReact"] } diff --git a/src/plugins/kibana_react/kibana.json b/src/plugins/kibana_react/kibana.json index 0add1bee84ae02..a507fe457b6335 100644 --- a/src/plugins/kibana_react/kibana.json +++ b/src/plugins/kibana_react/kibana.json @@ -1,5 +1,6 @@ { "id": "kibanaReact", "version": "kibana", - "ui": true + "ui": true, + "requiredBundles": ["kibanaUtils"] } diff --git a/src/plugins/management/kibana.json b/src/plugins/management/kibana.json index cc411a8c6a25c4..f48158e98ff3f2 100644 --- a/src/plugins/management/kibana.json +++ b/src/plugins/management/kibana.json @@ -3,5 +3,6 @@ "version": "kibana", "server": true, "ui": true, - "requiredPlugins": ["kibanaLegacy", "home"] + "requiredPlugins": ["kibanaLegacy", "home"], + "requiredBundles": ["kibanaReact"] } diff --git a/src/plugins/maps_legacy/kibana.json b/src/plugins/maps_legacy/kibana.json index cd503883164ace..d9bf33e6613684 100644 --- a/src/plugins/maps_legacy/kibana.json +++ b/src/plugins/maps_legacy/kibana.json @@ -4,5 +4,6 @@ "kibanaVersion": "kibana", "configPath": ["map"], "ui": true, - "server": true + "server": true, + "requiredBundles": ["kibanaReact", "charts"] } diff --git a/src/plugins/region_map/kibana.json b/src/plugins/region_map/kibana.json index ac7e1f8659d66d..6e1980c327dc05 100644 --- a/src/plugins/region_map/kibana.json +++ b/src/plugins/region_map/kibana.json @@ -11,5 +11,10 @@ "mapsLegacy", "kibanaLegacy", "data" + ], + "requiredBundles": [ + "kibanaUtils", + "kibanaReact", + "charts" ] } diff --git a/src/plugins/saved_objects/kibana.json b/src/plugins/saved_objects/kibana.json index 7ae1b84eecad80..589aafbd2aaf5e 100644 --- a/src/plugins/saved_objects/kibana.json +++ b/src/plugins/saved_objects/kibana.json @@ -3,5 +3,9 @@ "version": "kibana", "server": true, "ui": true, - "requiredPlugins": ["data"] + "requiredPlugins": ["data"], + "requiredBundles": [ + "kibanaUtils", + "kibanaReact" + ] } diff --git a/src/plugins/saved_objects_management/kibana.json b/src/plugins/saved_objects_management/kibana.json index 6184d890c415cf..0270c1d8f5d398 100644 --- a/src/plugins/saved_objects_management/kibana.json +++ b/src/plugins/saved_objects_management/kibana.json @@ -5,5 +5,6 @@ "ui": true, "requiredPlugins": ["home", "management", "data"], "optionalPlugins": ["dashboard", "visualizations", "discover"], - "extraPublicDirs": ["public/lib"] + "extraPublicDirs": ["public/lib"], + "requiredBundles": ["kibanaReact"] } diff --git a/src/plugins/share/kibana.json b/src/plugins/share/kibana.json index dce2ac9281aba7..7760ea321992dc 100644 --- a/src/plugins/share/kibana.json +++ b/src/plugins/share/kibana.json @@ -2,5 +2,6 @@ "id": "share", "version": "kibana", "server": true, - "ui": true + "ui": true, + "requiredBundles": ["kibanaUtils"] } diff --git a/src/plugins/telemetry/kibana.json b/src/plugins/telemetry/kibana.json index a4975977625207..520ca6076dbbd5 100644 --- a/src/plugins/telemetry/kibana.json +++ b/src/plugins/telemetry/kibana.json @@ -9,5 +9,9 @@ ], "extraPublicDirs": [ "common/constants" + ], + "requiredBundles": [ + "kibanaUtils", + "kibanaReact" ] } diff --git a/src/plugins/tile_map/kibana.json b/src/plugins/tile_map/kibana.json index bb8ef5a2465494..9881a2dd72308b 100644 --- a/src/plugins/tile_map/kibana.json +++ b/src/plugins/tile_map/kibana.json @@ -11,5 +11,10 @@ "mapsLegacy", "kibanaLegacy", "data" + ], + "requiredBundles": [ + "kibanaUtils", + "kibanaReact", + "charts" ] } diff --git a/src/plugins/ui_actions/kibana.json b/src/plugins/ui_actions/kibana.json index 907cbabbdf9c97..7b24b3cc5c48b5 100644 --- a/src/plugins/ui_actions/kibana.json +++ b/src/plugins/ui_actions/kibana.json @@ -5,5 +5,8 @@ "ui": true, "extraPublicDirs": [ "public/tests/test_samples" + ], + "requiredBundles": [ + "kibanaReact" ] } diff --git a/src/plugins/usage_collection/kibana.json b/src/plugins/usage_collection/kibana.json index ae86b6c5d7ad18..6ef78018c7d7fc 100644 --- a/src/plugins/usage_collection/kibana.json +++ b/src/plugins/usage_collection/kibana.json @@ -3,5 +3,8 @@ "configPath": ["usageCollection"], "version": "kibana", "server": true, - "ui": true + "ui": true, + "requiredBundles": [ + "kibanaUtils" + ] } diff --git a/src/plugins/vis_type_markdown/kibana.json b/src/plugins/vis_type_markdown/kibana.json index d52e22118ccf09..9241f5eeee8371 100644 --- a/src/plugins/vis_type_markdown/kibana.json +++ b/src/plugins/vis_type_markdown/kibana.json @@ -3,5 +3,6 @@ "version": "kibana", "ui": true, "server": true, - "requiredPlugins": ["expressions", "visualizations"] + "requiredPlugins": ["expressions", "visualizations"], + "requiredBundles": ["kibanaUtils", "kibanaReact", "data", "charts"] } diff --git a/src/plugins/vis_type_metric/kibana.json b/src/plugins/vis_type_metric/kibana.json index 24135d257b3177..b2ebc91471e9d3 100644 --- a/src/plugins/vis_type_metric/kibana.json +++ b/src/plugins/vis_type_metric/kibana.json @@ -4,5 +4,6 @@ "kibanaVersion": "kibana", "server": true, "ui": true, - "requiredPlugins": ["data", "visualizations", "charts","expressions"] + "requiredPlugins": ["data", "visualizations", "charts","expressions"], + "requiredBundles": ["kibanaUtils", "kibanaReact"] } diff --git a/src/plugins/vis_type_table/kibana.json b/src/plugins/vis_type_table/kibana.json index ed098d71614033..b3c15564290770 100644 --- a/src/plugins/vis_type_table/kibana.json +++ b/src/plugins/vis_type_table/kibana.json @@ -8,5 +8,11 @@ "visualizations", "data", "kibanaLegacy" + ], + "requiredBundles": [ + "kibanaUtils", + "kibanaReact", + "share", + "charts" ] } diff --git a/src/plugins/vis_type_tagcloud/kibana.json b/src/plugins/vis_type_tagcloud/kibana.json index dbc9a1b9ef6926..86f72ebfa936d7 100644 --- a/src/plugins/vis_type_tagcloud/kibana.json +++ b/src/plugins/vis_type_tagcloud/kibana.json @@ -3,5 +3,6 @@ "version": "kibana", "ui": true, "server": true, - "requiredPlugins": ["data", "expressions", "visualizations", "charts"] + "requiredPlugins": ["data", "expressions", "visualizations", "charts"], + "requiredBundles": ["kibanaUtils", "kibanaReact"] } diff --git a/src/plugins/vis_type_timelion/kibana.json b/src/plugins/vis_type_timelion/kibana.json index 85c282c51a2e7a..6946568f5d809d 100644 --- a/src/plugins/vis_type_timelion/kibana.json +++ b/src/plugins/vis_type_timelion/kibana.json @@ -4,5 +4,6 @@ "kibanaVersion": "kibana", "server": true, "ui": true, - "requiredPlugins": ["visualizations", "data", "expressions"] + "requiredPlugins": ["visualizations", "data", "expressions"], + "requiredBundles": ["kibanaUtils", "kibanaReact"] } diff --git a/src/plugins/vis_type_timeseries/kibana.json b/src/plugins/vis_type_timeseries/kibana.json index 9053d2543e0d0e..f2284726c463fb 100644 --- a/src/plugins/vis_type_timeseries/kibana.json +++ b/src/plugins/vis_type_timeseries/kibana.json @@ -5,5 +5,6 @@ "server": true, "ui": true, "requiredPlugins": ["charts", "data", "expressions", "visualizations"], - "optionalPlugins": ["usageCollection"] + "optionalPlugins": ["usageCollection"], + "requiredBundles": ["kibanaUtils", "kibanaReact"] } diff --git a/src/plugins/vis_type_vega/kibana.json b/src/plugins/vis_type_vega/kibana.json index f1f82e7f5b7ad3..d7a92de627a992 100644 --- a/src/plugins/vis_type_vega/kibana.json +++ b/src/plugins/vis_type_vega/kibana.json @@ -3,5 +3,6 @@ "version": "kibana", "server": true, "ui": true, - "requiredPlugins": ["data", "visualizations", "mapsLegacy", "expressions"] + "requiredPlugins": ["data", "visualizations", "mapsLegacy", "expressions"], + "requiredBundles": ["kibanaUtils", "kibanaReact"] } diff --git a/src/plugins/vis_type_vislib/kibana.json b/src/plugins/vis_type_vislib/kibana.json index cad0ebe01494a0..7cba2e0d6a6b4b 100644 --- a/src/plugins/vis_type_vislib/kibana.json +++ b/src/plugins/vis_type_vislib/kibana.json @@ -4,5 +4,6 @@ "server": true, "ui": true, "requiredPlugins": ["charts", "data", "expressions", "visualizations", "kibanaLegacy"], - "optionalPlugins": ["visTypeXy"] + "optionalPlugins": ["visTypeXy"], + "requiredBundles": ["kibanaUtils", "kibanaReact"] } diff --git a/src/plugins/visualizations/kibana.json b/src/plugins/visualizations/kibana.json index f3f9cbd8341ecf..da3edfbdd3bf5a 100644 --- a/src/plugins/visualizations/kibana.json +++ b/src/plugins/visualizations/kibana.json @@ -3,5 +3,6 @@ "version": "kibana", "server": true, "ui": true, - "requiredPlugins": ["data", "expressions", "uiActions", "embeddable", "usageCollection", "inspector"] + "requiredPlugins": ["data", "expressions", "uiActions", "embeddable", "usageCollection", "inspector"], + "requiredBundles": ["kibanaUtils", "discover", "savedObjects"] } diff --git a/src/plugins/visualize/kibana.json b/src/plugins/visualize/kibana.json index c27cfec24b332d..520d1e1daa6fe8 100644 --- a/src/plugins/visualize/kibana.json +++ b/src/plugins/visualize/kibana.json @@ -11,5 +11,11 @@ "visualizations", "embeddable" ], - "optionalPlugins": ["home", "share"] + "optionalPlugins": ["home", "share"], + "requiredBundles": [ + "kibanaUtils", + "kibanaReact", + "home", + "discover" + ] } diff --git a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/kibana.json b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/kibana.json index f0c1c3a34fbc09..7eafb185617c42 100644 --- a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/kibana.json +++ b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/kibana.json @@ -9,5 +9,8 @@ "expressions" ], "server": false, - "ui": true + "ui": true, + "requiredBundles": [ + "inspector" + ] } diff --git a/test/plugin_functional/plugins/app_link_test/kibana.json b/test/plugin_functional/plugins/app_link_test/kibana.json index 8cdc464abfec19..5384d4fee1508c 100644 --- a/test/plugin_functional/plugins/app_link_test/kibana.json +++ b/test/plugin_functional/plugins/app_link_test/kibana.json @@ -3,5 +3,6 @@ "version": "0.0.1", "kibanaVersion": "kibana", "server": false, - "ui": true + "ui": true, + "requiredBundles": ["kibanaReact"] } diff --git a/test/plugin_functional/plugins/kbn_sample_panel_action/kibana.json b/test/plugin_functional/plugins/kbn_sample_panel_action/kibana.json index 109afbcd5dabd4..08ce182aa02933 100644 --- a/test/plugin_functional/plugins/kbn_sample_panel_action/kibana.json +++ b/test/plugin_functional/plugins/kbn_sample_panel_action/kibana.json @@ -5,5 +5,6 @@ "configPath": ["kbn_sample_panel_action"], "server": false, "ui": true, - "requiredPlugins": ["uiActions", "embeddable"] -} \ No newline at end of file + "requiredPlugins": ["uiActions", "embeddable"], + "requiredBundles": ["kibanaReact"] +} diff --git a/test/scripts/jenkins_build_kibana.sh b/test/scripts/jenkins_build_kibana.sh index 3e49edc8e6ae5f..2310a35f94f331 100755 --- a/test/scripts/jenkins_build_kibana.sh +++ b/test/scripts/jenkins_build_kibana.sh @@ -2,16 +2,10 @@ source src/dev/ci_setup/setup_env.sh -echo " -> building examples separate from test plugins" +echo " -> building kibana platform plugins" node scripts/build_kibana_platform_plugins \ --oss \ - --examples \ - --verbose; - -echo " -> building test plugins" -node scripts/build_kibana_platform_plugins \ - --oss \ - --no-examples \ + --filter '!alertingExample' \ --scan-dir "$KIBANA_DIR/test/plugin_functional/plugins" \ --scan-dir "$KIBANA_DIR/test/interpreter_functional/plugins" \ --verbose; diff --git a/test/scripts/jenkins_xpack_build_kibana.sh b/test/scripts/jenkins_xpack_build_kibana.sh index 58ef6a42d3fe4c..c962b962b1e5e6 100755 --- a/test/scripts/jenkins_xpack_build_kibana.sh +++ b/test/scripts/jenkins_xpack_build_kibana.sh @@ -3,14 +3,8 @@ cd "$KIBANA_DIR" source src/dev/ci_setup/setup_env.sh -echo " -> building examples separate from test plugins" +echo " -> building kibana platform plugins" node scripts/build_kibana_platform_plugins \ - --examples \ - --verbose; - -echo " -> building test plugins" -node scripts/build_kibana_platform_plugins \ - --no-examples \ --scan-dir "$KIBANA_DIR/test/plugin_functional/plugins" \ --scan-dir "$XPACK_DIR/test/plugin_functional/plugins" \ --scan-dir "$XPACK_DIR/test/functional_with_es_ssl/fixtures/plugins" \ diff --git a/x-pack/examples/ui_actions_enhanced_examples/kibana.json b/x-pack/examples/ui_actions_enhanced_examples/kibana.json index a1cd895bb3cd62..160352a9afd660 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/kibana.json +++ b/x-pack/examples/ui_actions_enhanced_examples/kibana.json @@ -6,5 +6,9 @@ "server": false, "ui": true, "requiredPlugins": ["uiActionsEnhanced", "data", "discover"], - "optionalPlugins": [] + "optionalPlugins": [], + "requiredBundles": [ + "kibanaUtils", + "kibanaReact" + ] } diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index 56a9e226b65284..ee89abf59ee236 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -28,5 +28,10 @@ ], "extraPublicDirs": [ "public/style/variables" + ], + "requiredBundles": [ + "kibanaReact", + "kibanaUtils", + "observability" ] } diff --git a/x-pack/plugins/canvas/kibana.json b/x-pack/plugins/canvas/kibana.json index 2d6ab43228aa1c..5f4ea5802cb138 100644 --- a/x-pack/plugins/canvas/kibana.json +++ b/x-pack/plugins/canvas/kibana.json @@ -6,5 +6,6 @@ "server": true, "ui": true, "requiredPlugins": ["data", "embeddable", "expressions", "features", "home", "inspector", "uiActions"], - "optionalPlugins": ["usageCollection"] + "optionalPlugins": ["usageCollection"], + "requiredBundles": ["kibanaReact", "maps", "lens", "visualizations", "kibanaUtils", "kibanaLegacy", "discover", "savedObjects", "reporting"] } diff --git a/x-pack/plugins/cross_cluster_replication/kibana.json b/x-pack/plugins/cross_cluster_replication/kibana.json index ccf98f41def476..13746bb0e34c3a 100644 --- a/x-pack/plugins/cross_cluster_replication/kibana.json +++ b/x-pack/plugins/cross_cluster_replication/kibana.json @@ -13,5 +13,10 @@ "optionalPlugins": [ "usageCollection" ], - "configPath": ["xpack", "ccr"] + "configPath": ["xpack", "ccr"], + "requiredBundles": [ + "kibanaReact", + "esUiShared", + "data" + ] } diff --git a/x-pack/plugins/dashboard_enhanced/kibana.json b/x-pack/plugins/dashboard_enhanced/kibana.json index 3a95419d2f2fe0..ba5d8052ca7876 100644 --- a/x-pack/plugins/dashboard_enhanced/kibana.json +++ b/x-pack/plugins/dashboard_enhanced/kibana.json @@ -4,5 +4,10 @@ "server": false, "ui": true, "requiredPlugins": ["data", "uiActionsEnhanced", "embeddable", "dashboard", "share"], - "configPath": ["xpack", "dashboardEnhanced"] + "configPath": ["xpack", "dashboardEnhanced"], + "requiredBundles": [ + "kibanaUtils", + "embeddableEnhanced", + "kibanaReact" + ] } diff --git a/x-pack/plugins/data_enhanced/kibana.json b/x-pack/plugins/data_enhanced/kibana.json index 1be55d2b7a6352..f0baa84afca322 100644 --- a/x-pack/plugins/data_enhanced/kibana.json +++ b/x-pack/plugins/data_enhanced/kibana.json @@ -10,5 +10,6 @@ ], "optionalPlugins": ["kibanaReact", "kibanaUtils"], "server": true, - "ui": true + "ui": true, + "requiredBundles": ["kibanaReact", "kibanaUtils"] } diff --git a/x-pack/plugins/discover_enhanced/kibana.json b/x-pack/plugins/discover_enhanced/kibana.json index 704096ce7fcade..fbd04fe0096877 100644 --- a/x-pack/plugins/discover_enhanced/kibana.json +++ b/x-pack/plugins/discover_enhanced/kibana.json @@ -6,5 +6,6 @@ "ui": true, "requiredPlugins": ["uiActions", "embeddable", "discover"], "optionalPlugins": ["share"], - "configPath": ["xpack", "discoverEnhanced"] + "configPath": ["xpack", "discoverEnhanced"], + "requiredBundles": ["kibanaUtils", "data"] } diff --git a/x-pack/plugins/graph/kibana.json b/x-pack/plugins/graph/kibana.json index ebe18dba2b58c8..4e653393100c90 100644 --- a/x-pack/plugins/graph/kibana.json +++ b/x-pack/plugins/graph/kibana.json @@ -6,5 +6,6 @@ "ui": true, "requiredPlugins": ["licensing", "data", "navigation", "savedObjects", "kibanaLegacy"], "optionalPlugins": ["home", "features"], - "configPath": ["xpack", "graph"] + "configPath": ["xpack", "graph"], + "requiredBundles": ["kibanaUtils", "kibanaReact", "home"] } diff --git a/x-pack/plugins/grokdebugger/kibana.json b/x-pack/plugins/grokdebugger/kibana.json index 4d37f9ccdb0dec..8466c191ed9b6f 100644 --- a/x-pack/plugins/grokdebugger/kibana.json +++ b/x-pack/plugins/grokdebugger/kibana.json @@ -9,5 +9,8 @@ ], "server": true, "ui": true, - "configPath": ["xpack", "grokdebugger"] + "configPath": ["xpack", "grokdebugger"], + "requiredBundles": [ + "kibanaReact" + ] } diff --git a/x-pack/plugins/index_lifecycle_management/kibana.json b/x-pack/plugins/index_lifecycle_management/kibana.json index 6385646b957890..1a9f133b846fb7 100644 --- a/x-pack/plugins/index_lifecycle_management/kibana.json +++ b/x-pack/plugins/index_lifecycle_management/kibana.json @@ -12,5 +12,10 @@ "usageCollection", "indexManagement" ], - "configPath": ["xpack", "ilm"] + "configPath": ["xpack", "ilm"], + "requiredBundles": [ + "indexManagement", + "kibanaReact", + "esUiShared" + ] } diff --git a/x-pack/plugins/index_management/kibana.json b/x-pack/plugins/index_management/kibana.json index 40ecb26e8f0c96..6ab691054382ec 100644 --- a/x-pack/plugins/index_management/kibana.json +++ b/x-pack/plugins/index_management/kibana.json @@ -13,5 +13,9 @@ "usageCollection", "ingestManager" ], - "configPath": ["xpack", "index_management"] + "configPath": ["xpack", "index_management"], + "requiredBundles": [ + "kibanaReact", + "esUiShared" + ] } diff --git a/x-pack/plugins/infra/kibana.json b/x-pack/plugins/infra/kibana.json index e5ce1b1cd96f8a..06394c2aa916c2 100644 --- a/x-pack/plugins/infra/kibana.json +++ b/x-pack/plugins/infra/kibana.json @@ -16,5 +16,12 @@ "optionalPlugins": ["ml", "observability"], "server": true, "ui": true, - "configPath": ["xpack", "infra"] + "configPath": ["xpack", "infra"], + "requiredBundles": [ + "observability", + "licenseManagement", + "kibanaUtils", + "kibanaReact", + "apm" + ] } diff --git a/x-pack/plugins/ingest_manager/kibana.json b/x-pack/plugins/ingest_manager/kibana.json index 877184740166f5..ab0a2ba24ba663 100644 --- a/x-pack/plugins/ingest_manager/kibana.json +++ b/x-pack/plugins/ingest_manager/kibana.json @@ -6,5 +6,6 @@ "configPath": ["xpack", "ingestManager"], "requiredPlugins": ["licensing", "data", "encryptedSavedObjects"], "optionalPlugins": ["security", "features", "cloud", "usageCollection", "home"], - "extraPublicDirs": ["common"] + "extraPublicDirs": ["common"], + "requiredBundles": ["kibanaReact", "esUiShared"] } diff --git a/x-pack/plugins/ingest_pipelines/kibana.json b/x-pack/plugins/ingest_pipelines/kibana.json index cb24133b1f6ba4..75e5e9b5d6c514 100644 --- a/x-pack/plugins/ingest_pipelines/kibana.json +++ b/x-pack/plugins/ingest_pipelines/kibana.json @@ -5,5 +5,6 @@ "ui": true, "requiredPlugins": ["licensing", "management"], "optionalPlugins": ["security", "usageCollection"], - "configPath": ["xpack", "ingest_pipelines"] + "configPath": ["xpack", "ingest_pipelines"], + "requiredBundles": ["esUiShared", "kibanaReact"] } diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json index 7da5eaed5155ef..b8747fc1f0cde7 100644 --- a/x-pack/plugins/lens/kibana.json +++ b/x-pack/plugins/lens/kibana.json @@ -15,5 +15,6 @@ ], "optionalPlugins": ["embeddable", "usageCollection", "taskManager", "uiActions"], "configPath": ["xpack", "lens"], - "extraPublicDirs": ["common/constants"] + "extraPublicDirs": ["common/constants"], + "requiredBundles": ["savedObjects", "kibanaUtils", "kibanaReact", "embeddable"] } diff --git a/x-pack/plugins/license_management/kibana.json b/x-pack/plugins/license_management/kibana.json index 6da923c5cff5a3..3dbf99fced0b07 100644 --- a/x-pack/plugins/license_management/kibana.json +++ b/x-pack/plugins/license_management/kibana.json @@ -6,5 +6,9 @@ "requiredPlugins": ["home", "licensing", "management"], "optionalPlugins": ["telemetry"], "configPath": ["xpack", "license_management"], - "extraPublicDirs": ["common/constants"] + "extraPublicDirs": ["common/constants"], + "requiredBundles": [ + "telemetryManagementSection", + "kibanaReact" + ] } diff --git a/x-pack/plugins/licensing/kibana.json b/x-pack/plugins/licensing/kibana.json index 9edaa726c6ba96..2d38a82271eb03 100644 --- a/x-pack/plugins/licensing/kibana.json +++ b/x-pack/plugins/licensing/kibana.json @@ -4,5 +4,6 @@ "kibanaVersion": "kibana", "configPath": ["xpack", "licensing"], "server": true, - "ui": true + "ui": true, + "requiredBundles": ["kibanaReact"] } diff --git a/x-pack/plugins/logstash/kibana.json b/x-pack/plugins/logstash/kibana.json index 1eb325dcc1610b..5949d5db041f2e 100644 --- a/x-pack/plugins/logstash/kibana.json +++ b/x-pack/plugins/logstash/kibana.json @@ -13,5 +13,6 @@ "security" ], "server": true, - "ui": true + "ui": true, + "requiredBundles": ["home"] } diff --git a/x-pack/plugins/maps/kibana.json b/x-pack/plugins/maps/kibana.json index f8a30b8d0337e4..e422efb31cb0d7 100644 --- a/x-pack/plugins/maps/kibana.json +++ b/x-pack/plugins/maps/kibana.json @@ -19,5 +19,11 @@ ], "ui": true, "server": true, - "extraPublicDirs": ["common/constants"] + "extraPublicDirs": ["common/constants"], + "requiredBundles": [ + "charts", + "kibanaReact", + "kibanaUtils", + "savedObjects" + ] } diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index f93e7bc19f9603..a08b9b6d97116a 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -25,5 +25,13 @@ "licenseManagement" ], "server": true, - "ui": true + "ui": true, + "requiredBundles": [ + "esUiShared", + "kibanaUtils", + "kibanaReact", + "management", + "dashboard", + "savedObjects" + ] } diff --git a/x-pack/plugins/monitoring/kibana.json b/x-pack/plugins/monitoring/kibana.json index c3000218aa1253..65dd4b373a71aa 100644 --- a/x-pack/plugins/monitoring/kibana.json +++ b/x-pack/plugins/monitoring/kibana.json @@ -6,5 +6,6 @@ "requiredPlugins": ["licensing", "features", "data", "navigation", "kibanaLegacy"], "optionalPlugins": ["alerts", "actions", "infra", "telemetryCollectionManager", "usageCollection", "home", "cloud"], "server": true, - "ui": true + "ui": true, + "requiredBundles": ["kibanaUtils", "home", "alerts", "kibanaReact", "licenseManagement"] } diff --git a/x-pack/plugins/observability/kibana.json b/x-pack/plugins/observability/kibana.json index 712a46f76bb747..2a04a35830a471 100644 --- a/x-pack/plugins/observability/kibana.json +++ b/x-pack/plugins/observability/kibana.json @@ -10,5 +10,10 @@ "licensing" ], "ui": true, - "server": true + "server": true, + "requiredBundles": [ + "data", + "kibanaReact", + "kibanaUtils" + ] } diff --git a/x-pack/plugins/painless_lab/kibana.json b/x-pack/plugins/painless_lab/kibana.json index 4b4ea24202846e..ca97e73704e705 100644 --- a/x-pack/plugins/painless_lab/kibana.json +++ b/x-pack/plugins/painless_lab/kibana.json @@ -12,5 +12,8 @@ "painless_lab" ], "server": true, - "ui": true + "ui": true, + "requiredBundles": [ + "kibanaReact" + ] } diff --git a/x-pack/plugins/remote_clusters/kibana.json b/x-pack/plugins/remote_clusters/kibana.json index f1b9d20f762d36..d90d6ea460573d 100644 --- a/x-pack/plugins/remote_clusters/kibana.json +++ b/x-pack/plugins/remote_clusters/kibana.json @@ -15,5 +15,9 @@ "cloud" ], "server": true, - "ui": true + "ui": true, + "requiredBundles": [ + "kibanaReact", + "esUiShared" + ] } diff --git a/x-pack/plugins/reporting/kibana.json b/x-pack/plugins/reporting/kibana.json index bc1a808d500e09..a5d7f3d20c44c9 100644 --- a/x-pack/plugins/reporting/kibana.json +++ b/x-pack/plugins/reporting/kibana.json @@ -17,5 +17,9 @@ "share" ], "server": true, - "ui": true + "ui": true, + "requiredBundles": [ + "kibanaReact", + "discover" + ] } diff --git a/x-pack/plugins/rollup/kibana.json b/x-pack/plugins/rollup/kibana.json index f897051d3ed8a6..e6915f65599ccf 100644 --- a/x-pack/plugins/rollup/kibana.json +++ b/x-pack/plugins/rollup/kibana.json @@ -15,5 +15,12 @@ "usageCollection", "visTypeTimeseries" ], - "configPath": ["xpack", "rollup"] + "configPath": ["xpack", "rollup"], + "requiredBundles": [ + "kibanaUtils", + "kibanaReact", + "home", + "esUiShared", + "data" + ] } diff --git a/x-pack/plugins/searchprofiler/kibana.json b/x-pack/plugins/searchprofiler/kibana.json index f92083ee9d9fe5..a5e42f20b5c7af 100644 --- a/x-pack/plugins/searchprofiler/kibana.json +++ b/x-pack/plugins/searchprofiler/kibana.json @@ -5,5 +5,6 @@ "requiredPlugins": ["devTools", "home", "licensing"], "configPath": ["xpack", "searchprofiler"], "server": true, - "ui": true + "ui": true, + "requiredBundles": ["esUiShared"] } diff --git a/x-pack/plugins/security/kibana.json b/x-pack/plugins/security/kibana.json index 7d1940e393becf..0daab9d5dbce31 100644 --- a/x-pack/plugins/security/kibana.json +++ b/x-pack/plugins/security/kibana.json @@ -6,5 +6,13 @@ "requiredPlugins": ["data", "features", "licensing"], "optionalPlugins": ["home", "management"], "server": true, - "ui": true + "ui": true, + "requiredBundles": [ + "home", + "management", + "kibanaReact", + "spaces", + "esUiShared", + "management" + ] } diff --git a/x-pack/plugins/security_solution/kibana.json b/x-pack/plugins/security_solution/kibana.json index f6f2d5171312cc..40d3402378895c 100644 --- a/x-pack/plugins/security_solution/kibana.json +++ b/x-pack/plugins/security_solution/kibana.json @@ -29,5 +29,10 @@ "lists" ], "server": true, - "ui": true + "ui": true, + "requiredBundles": [ + "kibanaUtils", + "esUiShared", + "kibanaReact" + ] } diff --git a/x-pack/plugins/snapshot_restore/kibana.json b/x-pack/plugins/snapshot_restore/kibana.json index df72102e520862..92f3e27d6d5b82 100644 --- a/x-pack/plugins/snapshot_restore/kibana.json +++ b/x-pack/plugins/snapshot_restore/kibana.json @@ -13,5 +13,9 @@ "security", "cloud" ], - "configPath": ["xpack", "snapshot_restore"] + "configPath": ["xpack", "snapshot_restore"], + "requiredBundles": [ + "esUiShared", + "kibanaReact" + ] } diff --git a/x-pack/plugins/spaces/kibana.json b/x-pack/plugins/spaces/kibana.json index 9483cb67392c43..0698535cc15fd7 100644 --- a/x-pack/plugins/spaces/kibana.json +++ b/x-pack/plugins/spaces/kibana.json @@ -14,5 +14,11 @@ ], "server": true, "ui": true, - "extraPublicDirs": ["common"] + "extraPublicDirs": ["common"], + "requiredBundles": [ + "kibanaReact", + "savedObjectsManagement", + "management", + "home" + ] } diff --git a/x-pack/plugins/transform/kibana.json b/x-pack/plugins/transform/kibana.json index 391a95853cc16c..d7e7a7fabba4fd 100644 --- a/x-pack/plugins/transform/kibana.json +++ b/x-pack/plugins/transform/kibana.json @@ -13,5 +13,13 @@ "security", "usageCollection" ], - "configPath": ["xpack", "transform"] + "configPath": ["xpack", "transform"], + "requiredBundles": [ + "ml", + "esUiShared", + "discover", + "kibanaUtils", + "kibanaReact", + "savedObjects" + ] } diff --git a/x-pack/plugins/triggers_actions_ui/kibana.json b/x-pack/plugins/triggers_actions_ui/kibana.json index 158cfa100d5463..03fef90e8ca7f4 100644 --- a/x-pack/plugins/triggers_actions_ui/kibana.json +++ b/x-pack/plugins/triggers_actions_ui/kibana.json @@ -6,5 +6,6 @@ "optionalPlugins": ["alerts", "alertingBuiltins"], "requiredPlugins": ["management", "charts", "data"], "configPath": ["xpack", "trigger_actions_ui"], - "extraPublicDirs": ["public/common", "public/common/constants"] + "extraPublicDirs": ["public/common", "public/common/constants"], + "requiredBundles": ["alerts", "esUiShared"] } diff --git a/x-pack/plugins/ui_actions_enhanced/kibana.json b/x-pack/plugins/ui_actions_enhanced/kibana.json index a813903d8b2124..108c66505f25c6 100644 --- a/x-pack/plugins/ui_actions_enhanced/kibana.json +++ b/x-pack/plugins/ui_actions_enhanced/kibana.json @@ -8,5 +8,10 @@ "licensing" ], "server": false, - "ui": true + "ui": true, + "requiredBundles": [ + "kibanaUtils", + "kibanaReact", + "data" + ] } diff --git a/x-pack/plugins/upgrade_assistant/kibana.json b/x-pack/plugins/upgrade_assistant/kibana.json index 273036a653aeb8..31109dd963ab45 100644 --- a/x-pack/plugins/upgrade_assistant/kibana.json +++ b/x-pack/plugins/upgrade_assistant/kibana.json @@ -5,5 +5,6 @@ "ui": true, "configPath": ["xpack", "upgrade_assistant"], "requiredPlugins": ["management", "licensing"], - "optionalPlugins": ["cloud", "usageCollection"] + "optionalPlugins": ["cloud", "usageCollection"], + "requiredBundles": ["management"] } diff --git a/x-pack/plugins/uptime/kibana.json b/x-pack/plugins/uptime/kibana.json index 152839836ad997..a057e546e4414a 100644 --- a/x-pack/plugins/uptime/kibana.json +++ b/x-pack/plugins/uptime/kibana.json @@ -13,5 +13,14 @@ ], "server": true, "ui": true, - "version": "8.0.0" + "version": "8.0.0", + "requiredBundles": [ + "observability", + "kibanaReact", + "home", + "data", + "ml", + "apm", + "maps" + ] } diff --git a/x-pack/plugins/watcher/kibana.json b/x-pack/plugins/watcher/kibana.json index a0482ad00797ce..ba6a9bfa5e1949 100644 --- a/x-pack/plugins/watcher/kibana.json +++ b/x-pack/plugins/watcher/kibana.json @@ -10,5 +10,9 @@ "data" ], "server": true, - "ui": true + "ui": true, + "requiredBundles": [ + "esUiShared", + "kibanaReact" + ] } From f5b77cd7096ead477646b8206a3ef47a27a2ac5b Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Thu, 9 Jul 2020 20:36:20 -0600 Subject: [PATCH 36/49] [SIEM][Detection Engine][Lists] Adds read_privileges route for lists and list items ## Summary * Adds a read_privileges for the list and list items. Run the script: get_privileges.sh API: ```ts GET /api/lists/privileges { "listItems": { "username": "yo", "has_all_requested": false, "cluster": { "monitor_ml": true, "manage_ccr": true, "manage_index_templates": true, "monitor_watcher": true, "monitor_transform": true, "read_ilm": true, "manage_api_key": true, "manage_security": true, "manage_own_api_key": false, "manage_saml": true, "all": true, "manage_ilm": true, "manage_ingest_pipelines": true, "read_ccr": true, "manage_rollup": true, "monitor": true, "manage_watcher": true, "manage": true, "manage_transform": true, "manage_token": true, "manage_ml": true, "manage_pipeline": true, "monitor_rollup": true, "transport_client": true, "create_snapshot": true }, "index": { ".lists-frank-default": { "all": true, "manage_ilm": true, "read": true, "create_index": true, "read_cross_cluster": true, "index": true, "monitor": true, "delete": true, "manage": true, "delete_index": true, "create_doc": true, "view_index_metadata": true, "create": true, "manage_follow_index": true, "manage_leader_index": true, "write": true } }, "application": {} }, "lists": { "username": "yo", "has_all_requested": false, "cluster": { "monitor_ml": true, "manage_ccr": true, "manage_index_templates": true, "monitor_watcher": true, "monitor_transform": true, "read_ilm": true, "manage_api_key": true, "manage_security": true, "manage_own_api_key": false, "manage_saml": true, "all": true, "manage_ilm": true, "manage_ingest_pipelines": true, "read_ccr": true, "manage_rollup": true, "monitor": true, "manage_watcher": true, "manage": true, "manage_transform": true, "manage_token": true, "manage_ml": true, "manage_pipeline": true, "monitor_rollup": true, "transport_client": true, "create_snapshot": true }, "index": { ".lists-frank-default": { "all": true, "manage_ilm": true, "read": true, "create_index": true, "read_cross_cluster": true, "index": true, "monitor": true, "delete": true, "manage": true, "delete_index": true, "create_doc": true, "view_index_metadata": true, "create": true, "manage_follow_index": true, "manage_leader_index": true, "write": true } }, "application": {} }, "is_authenticated": true } ``` ### Checklist We currently have not ported over patterns for the routes so we do not have sanity checks against this or other routes and no end point tests which is why the check box is not checked below at this point in time. We are implementing those tests during the feature freeze (hopefully) - [ ] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios --- x-pack/plugins/lists/common/constants.ts | 1 + x-pack/plugins/lists/server/plugin.ts | 2 +- .../lists/server/routes/init_routes.ts | 10 +++- .../server/routes/read_privileges_route.ts | 60 +++++++++++++++++++ .../lists/server/scripts/get_privileges.sh | 15 +++++ .../plugins/lists/server/siem_server_deps.ts | 1 + .../plugins/security_solution/server/index.ts | 1 + 7 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 x-pack/plugins/lists/server/routes/read_privileges_route.ts create mode 100755 x-pack/plugins/lists/server/scripts/get_privileges.sh diff --git a/x-pack/plugins/lists/common/constants.ts b/x-pack/plugins/lists/common/constants.ts index 6cb88b19483cef..af29b3aa53dedb 100644 --- a/x-pack/plugins/lists/common/constants.ts +++ b/x-pack/plugins/lists/common/constants.ts @@ -10,6 +10,7 @@ export const LIST_URL = '/api/lists'; export const LIST_INDEX = `${LIST_URL}/index`; export const LIST_ITEM_URL = `${LIST_URL}/items`; +export const LIST_PRIVILEGES_URL = `${LIST_URL}/privileges`; /** * Exception list routes diff --git a/x-pack/plugins/lists/server/plugin.ts b/x-pack/plugins/lists/server/plugin.ts index b4f2639f24923f..118bb2f927a645 100644 --- a/x-pack/plugins/lists/server/plugin.ts +++ b/x-pack/plugins/lists/server/plugin.ts @@ -48,7 +48,7 @@ export class ListPlugin core.http.registerRouteHandlerContext('lists', this.createRouteHandlerContext()); const router = core.http.createRouter(); - initRoutes(router, config); + initRoutes(router, config, plugins.security); return { getExceptionListClient: (savedObjectsClient, user): ExceptionListClient => { diff --git a/x-pack/plugins/lists/server/routes/init_routes.ts b/x-pack/plugins/lists/server/routes/init_routes.ts index ffd8afd54913f0..fef7f19f02df2d 100644 --- a/x-pack/plugins/lists/server/routes/init_routes.ts +++ b/x-pack/plugins/lists/server/routes/init_routes.ts @@ -6,8 +6,11 @@ import { IRouter } from 'kibana/server'; +import { SecurityPluginSetup } from '../../../security/server'; import { ConfigType } from '../config'; +import { readPrivilegesRoute } from './read_privileges_route'; + import { createExceptionListItemRoute, createExceptionListRoute, @@ -38,7 +41,11 @@ import { updateListRoute, } from '.'; -export const initRoutes = (router: IRouter, config: ConfigType): void => { +export const initRoutes = ( + router: IRouter, + config: ConfigType, + security: SecurityPluginSetup | null | undefined +): void => { // lists createListRoute(router); readListRoute(router); @@ -46,6 +53,7 @@ export const initRoutes = (router: IRouter, config: ConfigType): void => { deleteListRoute(router); patchListRoute(router); findListRoute(router); + readPrivilegesRoute(router, security); // list items createListItemRoute(router); diff --git a/x-pack/plugins/lists/server/routes/read_privileges_route.ts b/x-pack/plugins/lists/server/routes/read_privileges_route.ts new file mode 100644 index 00000000000000..892b6406a28ecc --- /dev/null +++ b/x-pack/plugins/lists/server/routes/read_privileges_route.ts @@ -0,0 +1,60 @@ +/* + * 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 { IRouter } from 'kibana/server'; +import { merge } from 'lodash/fp'; + +import { SecurityPluginSetup } from '../../../security/server'; +import { LIST_PRIVILEGES_URL } from '../../common/constants'; +import { buildSiemResponse, readPrivileges, transformError } from '../siem_server_deps'; + +import { getListClient } from './utils'; + +export const readPrivilegesRoute = ( + router: IRouter, + security: SecurityPluginSetup | null | undefined +): void => { + router.get( + { + options: { + tags: ['access:lists'], + }, + path: LIST_PRIVILEGES_URL, + validate: false, + }, + async (context, request, response) => { + const siemResponse = buildSiemResponse(response); + try { + const clusterClient = context.core.elasticsearch.legacy.client; + const lists = getListClient(context); + const clusterPrivilegesLists = await readPrivileges( + clusterClient.callAsCurrentUser, + lists.getListIndex() + ); + const clusterPrivilegesListItems = await readPrivileges( + clusterClient.callAsCurrentUser, + lists.getListIndex() + ); + const privileges = merge( + { + listItems: clusterPrivilegesListItems, + lists: clusterPrivilegesLists, + }, + { + is_authenticated: security?.authc.isAuthenticated(request) ?? false, + } + ); + return response.ok({ body: privileges }); + } catch (err) { + const error = transformError(err); + return siemResponse.error({ + body: error.message, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/plugins/lists/server/scripts/get_privileges.sh b/x-pack/plugins/lists/server/scripts/get_privileges.sh new file mode 100755 index 00000000000000..4c02747f3c56c8 --- /dev/null +++ b/x-pack/plugins/lists/server/scripts/get_privileges.sh @@ -0,0 +1,15 @@ +#!/bin/sh + +# +# 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. +# + +set -e +./check_env_variables.sh + +# Example: ./get_privileges.sh +curl -s -k \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET ${KIBANA_URL}${SPACE_URL}/api/lists/privileges | jq . diff --git a/x-pack/plugins/lists/server/siem_server_deps.ts b/x-pack/plugins/lists/server/siem_server_deps.ts index 87a623c7a18924..324103b7fb50de 100644 --- a/x-pack/plugins/lists/server/siem_server_deps.ts +++ b/x-pack/plugins/lists/server/siem_server_deps.ts @@ -17,4 +17,5 @@ export { createBootstrapIndex, getIndexExists, buildRouteValidation, + readPrivileges, } from '../../security_solution/server'; diff --git a/x-pack/plugins/security_solution/server/index.ts b/x-pack/plugins/security_solution/server/index.ts index 06b35213b4713a..7b84c531dd376d 100644 --- a/x-pack/plugins/security_solution/server/index.ts +++ b/x-pack/plugins/security_solution/server/index.ts @@ -54,3 +54,4 @@ export { createBootstrapIndex } from './lib/detection_engine/index/create_bootst export { getIndexExists } from './lib/detection_engine/index/get_index_exists'; export { buildRouteValidation } from './utils/build_validation/route_validation'; export { transformError, buildSiemResponse } from './lib/detection_engine/routes/utils'; +export { readPrivileges } from './lib/detection_engine/privileges/read_privileges'; From d8e9327db488fafd93ea8b33d9b34bc952c46df9 Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Thu, 9 Jul 2020 20:36:51 -0600 Subject: [PATCH 37/49] [SIEM][Detection Engine] Fixes skipped tests (#71347) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary * https://github.com/elastic/kibana/issues/69632 * Adds a retry loop in case of a network outage/issue which should increase the chances of success * If there is still an issue after the 20th try, then it moves on and there is a high likelihood the tests will continue without issues. * Adds console logging statements so we know if this flakiness happens again a bit more insight into why the network is behaving the way it is. * Helps prevent the other tests from being skipped in the future due to bad networking issues. The errors that were coming back from the failed tests are in the `afterEach` and look to be network related or another test interfering: ```ts 1) detection engine api security and spaces enabled 01:59:54 find_statuses 01:59:54 "after each" hook for "should return a single rule status when a single rule is loaded from a find status with defaults added": 01:59:54 ResponseError: Response Error 01:59:54 at IncomingMessage.response.on (/dev/shm/workspace/kibana/node_modules/@elastic/elasticsearch/lib/Transport.js:287:25) 01:59:54 at endReadableNT (_stream_readable.js:1145:12) 01:59:54 at process._tickCallback (internal/process/next_tick.js:63:19) 01:59:54 01:59:54 └- ✖ fail: "detection engine api security and spaces enabled find_statuses "after each" hook for "should return a single rule status when a single rule is loaded from a find status with defaults added"" 01:59:54 │ 01:59:54 └-> "after all" hook 01:59:54 └-> "after all" hook 01:59:54 │ 01:59:54 │42 passing (2.0m) 01:59:54 │1 failing ``` So this should fix it to where the afterEach calls try up to 20 times before giving up and then on giving up they move on with the hope a different test doesn't fail. ### Checklist - [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios --- .../basic/tests/find_statuses.ts | 3 +- .../detection_engine_api_integration/utils.ts | 95 +++++++++++++++---- 2 files changed, 76 insertions(+), 22 deletions(-) diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/find_statuses.ts b/x-pack/test/detection_engine_api_integration/basic/tests/find_statuses.ts index c88b094879ac8b..cc6fa53939f60e 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/find_statuses.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/find_statuses.ts @@ -22,8 +22,7 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - // FLAKY: https://github.com/elastic/kibana/issues/69632 - describe.skip('find_statuses', () => { + describe('find_statuses', () => { beforeEach(async () => { await createSignalsIndex(supertest); }); diff --git a/x-pack/test/detection_engine_api_integration/utils.ts b/x-pack/test/detection_engine_api_integration/utils.ts index 6ad9cf4cd5baf4..4b980536d2cd10 100644 --- a/x-pack/test/detection_engine_api_integration/utils.ts +++ b/x-pack/test/detection_engine_api_integration/utils.ts @@ -235,40 +235,83 @@ export const getSimpleMlRuleOutput = (ruleId = 'rule-1'): Partial = /** * Remove all alerts from the .kibana index + * This will retry 20 times before giving up and hopefully still not interfere with other tests * @param es The ElasticSearch handle */ -export const deleteAllAlerts = async (es: Client): Promise => { - await es.deleteByQuery({ - index: '.kibana', - q: 'type:alert', - wait_for_completion: true, - refresh: true, - body: {}, - }); +export const deleteAllAlerts = async (es: Client, retryCount = 20): Promise => { + if (retryCount > 0) { + try { + await es.deleteByQuery({ + index: '.kibana', + q: 'type:alert', + wait_for_completion: true, + refresh: true, + body: {}, + }); + } catch (err) { + // eslint-disable-next-line no-console + console.log(`Failure trying to deleteAllAlerts, retries left are: ${retryCount - 1}`, err); + await deleteAllAlerts(es, retryCount - 1); + } + } else { + // eslint-disable-next-line no-console + console.log('Could not deleteAllAlerts, no retries are left'); + } }; /** * Remove all rules statuses from the .kibana index + * This will retry 20 times before giving up and hopefully still not interfere with other tests * @param es The ElasticSearch handle */ -export const deleteAllRulesStatuses = async (es: Client): Promise => { - await es.deleteByQuery({ - index: '.kibana', - q: 'type:siem-detection-engine-rule-status', - wait_for_completion: true, - refresh: true, - body: {}, - }); +export const deleteAllRulesStatuses = async (es: Client, retryCount = 20): Promise => { + if (retryCount > 0) { + try { + await es.deleteByQuery({ + index: '.kibana', + q: 'type:siem-detection-engine-rule-status', + wait_for_completion: true, + refresh: true, + body: {}, + }); + } catch (err) { + // eslint-disable-next-line no-console + console.log( + `Failure trying to deleteAllRulesStatuses, retries left are: ${retryCount - 1}`, + err + ); + await deleteAllRulesStatuses(es, retryCount - 1); + } + } else { + // eslint-disable-next-line no-console + console.log('Could not deleteAllRulesStatuses, no retries are left'); + } }; /** * Creates the signals index for use inside of beforeEach blocks of tests + * This will retry 20 times before giving up and hopefully still not interfere with other tests * @param supertest The supertest client library */ export const createSignalsIndex = async ( - supertest: SuperTest + supertest: SuperTest, + retryCount = 20 ): Promise => { - await supertest.post(DETECTION_ENGINE_INDEX_URL).set('kbn-xsrf', 'true').send().expect(200); + if (retryCount > 0) { + try { + await supertest.post(DETECTION_ENGINE_INDEX_URL).set('kbn-xsrf', 'true').send(); + } catch (err) { + // eslint-disable-next-line no-console + console.log( + `Failure trying to create the signals index, retries left are: ${retryCount - 1}`, + err + ); + await createSignalsIndex(supertest, retryCount - 1); + } + } else { + // eslint-disable-next-line no-console + console.log('Could not createSignalsIndex, no retries are left'); + } }; /** @@ -276,9 +319,21 @@ export const createSignalsIndex = async ( * @param supertest The supertest client library */ export const deleteSignalsIndex = async ( - supertest: SuperTest + supertest: SuperTest, + retryCount = 20 ): Promise => { - await supertest.delete(DETECTION_ENGINE_INDEX_URL).set('kbn-xsrf', 'true').send().expect(200); + if (retryCount > 0) { + try { + await supertest.delete(DETECTION_ENGINE_INDEX_URL).set('kbn-xsrf', 'true').send(); + } catch (err) { + // eslint-disable-next-line no-console + console.log(`Failure trying to deleteSignalsIndex, retries left are: ${retryCount - 1}`, err); + await deleteSignalsIndex(supertest, retryCount - 1); + } + } else { + // eslint-disable-next-line no-console + console.log('Could not deleteSignalsIndex, no retries are left'); + } }; /** From afe785b43a633c351d684a243057516b68da4d25 Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Thu, 9 Jul 2020 19:42:48 -0700 Subject: [PATCH 38/49] [build] Creates Linux aarch64 archive (#69165) - Updates Linux Chromium builds to accept architecture argument (defaults to x64) for arm64 support. - Example: `python ~/chromium/build_chromium/build.py 312d84c8ce62810976feda0d3457108a6dfff9e6 arm64` - Updates all Chromium builds to include architecture in filename. - `chromium-312d84c-linux_arm64.zip` _(new)_ - `chromium-312d84c-linux.zip` > `chromium-312d84c-linux_x64.zip` - Moves Chromium install from data directory to `x-pack/plugins/reporting/chromium` - Moves Chromium download cache from `x-pack/plugins/reporting/.chromium` to `.chromium` - Installs Chromium during build (closes #53664) - Updates build to be architecture aware (x64 and aarch64) - Removed Chromium debug logs, they were not helpful and can not be written inside the Kibana root. If we were to keep them, we would need to write to `logging.dist`. Signed-off-by: Tyler Smalley --- .ci/packer_cache_for_branch.sh | 2 +- .eslintignore | 1 + .gitignore | 1 + docs/user/reporting/index.asciidoc | 2 +- src/cli/cluster/cluster_manager.ts | 2 +- src/dev/build/README.md | 2 +- src/dev/build/build_distributables.js | 16 +++--- src/dev/build/lib/__tests__/config.js | 53 ++++++++--------- src/dev/build/lib/__tests__/platform.js | 26 +++++---- src/dev/build/lib/config.js | 52 ++++++----------- src/dev/build/lib/platform.js | 14 +++-- src/dev/build/tasks/clean_tasks.js | 39 ------------- .../tasks/create_archives_sources_task.js | 4 +- src/dev/build/tasks/index.js | 9 +-- src/dev/build/tasks/install_chromium.js | 44 ++++++++++++++ src/dev/build/tasks/notice_file_task.js | 2 +- src/dev/build/tasks/os_packages/run_fpm.js | 2 +- .../build/tasks/patch_native_modules_task.js | 2 +- x-pack/.gitignore | 2 +- x-pack/build_chromium/README.md | 31 +++++----- x-pack/build_chromium/build.py | 26 +++++++-- x-pack/build_chromium/init.py | 8 ++- x-pack/build_chromium/linux/init.sh | 2 +- x-pack/gulpfile.js | 2 - .../browsers/chromium/driver_factory/args.ts | 4 -- .../browsers/chromium/driver_factory/index.ts | 2 - .../server/browsers/chromium/paths.ts | 16 ++++-- .../server/browsers/download/clean.ts | 2 +- .../browsers/download/ensure_downloaded.ts | 13 +---- .../server/browsers/download/index.ts | 2 +- .../reporting/server/browsers/index.ts | 3 +- .../reporting/server/browsers/install.ts | 57 +++++++------------ x-pack/tasks/build.ts | 2 - x-pack/tasks/dev.ts | 4 +- x-pack/tasks/prepare.ts | 25 -------- 35 files changed, 219 insertions(+), 255 deletions(-) create mode 100644 src/dev/build/tasks/install_chromium.js delete mode 100644 x-pack/tasks/prepare.ts diff --git a/.ci/packer_cache_for_branch.sh b/.ci/packer_cache_for_branch.sh index a9fbe781915b68..5b4a94be50fa28 100755 --- a/.ci/packer_cache_for_branch.sh +++ b/.ci/packer_cache_for_branch.sh @@ -46,7 +46,7 @@ echo "Creating bootstrap_cache archive" # archive cacheable directories mkdir -p "$HOME/.kibana/bootstrap_cache" tar -cf "$HOME/.kibana/bootstrap_cache/$branch.tar" \ - x-pack/plugins/reporting/.chromium \ + .chromium \ .es \ .chromedriver \ .geckodriver; diff --git a/.eslintignore b/.eslintignore index 9de2cc28729602..4b5e781c26971a 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,6 +1,7 @@ **/*.js.snap **/graphql/types.ts /.es +/.chromium /build /built_assets /config/apm.dev.js diff --git a/.gitignore b/.gitignore index 32377ec0f1ffe8..716cea937f9c0a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .signing-config.json .ackrc /.es +/.chromium .DS_Store .node_binaries .native_modules diff --git a/docs/user/reporting/index.asciidoc b/docs/user/reporting/index.asciidoc index 4123912b79237a..6acdbbe3f0a99e 100644 --- a/docs/user/reporting/index.asciidoc +++ b/docs/user/reporting/index.asciidoc @@ -19,7 +19,7 @@ image::user/reporting/images/share-button.png["Share"] [float] == Setup -{reporting} is automatically enabled in {kib}. The first time {kib} runs, it extracts a custom build for the Chromium web browser, which +{reporting} is automatically enabled in {kib}. It runs a custom build of the Chromium web browser, which runs on the server in headless mode to load {kib} and capture the rendered {kib} charts as images. Chromium is an open-source project not related to Elastic, but the Chromium binary for {kib} has been custom-built by Elastic to ensure it diff --git a/src/cli/cluster/cluster_manager.ts b/src/cli/cluster/cluster_manager.ts index 6db6199b391e14..f193f33e6f47e8 100644 --- a/src/cli/cluster/cluster_manager.ts +++ b/src/cli/cluster/cluster_manager.ts @@ -261,7 +261,7 @@ export class ClusterManager { /debug\.log$/, ...pluginInternalDirsIgnore, fromRoot('src/legacy/server/sass/__tmp__'), - fromRoot('x-pack/plugins/reporting/.chromium'), + fromRoot('x-pack/plugins/reporting/chromium'), fromRoot('x-pack/plugins/security_solution/cypress'), fromRoot('x-pack/plugins/apm/e2e'), fromRoot('x-pack/plugins/apm/scripts'), diff --git a/src/dev/build/README.md b/src/dev/build/README.md index 3b579033fabe1f..ed8750f6fee568 100644 --- a/src/dev/build/README.md +++ b/src/dev/build/README.md @@ -24,7 +24,7 @@ The majority of this logic is extracted from the grunt build that has existed fo **Config**: [lib/config.js] defines the config used to execute tasks. It is mostly used to determine absolute paths to specific locations, and to get access to the Platforms. -**Platform**: [lib/platform.js] defines the Platform objects, which define the different platforms we build for. Use `config.getTargetPlatforms()` to get the list of platforms we are targeting in this build, `config.getNodePlatforms()` to get the list of platform we will download node for, or `config.getLinux/Windows/MacPlatform()` to get a specific platform. +**Platform**: [lib/platform.js] defines the Platform objects, which define the different platforms we build for. Use `config.getTargetPlatforms()` to get the list of platforms we are targeting in this build, `config.getNodePlatforms()` to get the list of platform we will download node for, or `config.getPlatform` to get a specific platform and architecture. **Log**: We uses the `ToolingLog` defined in [../tooling_log/tooling_log.js] diff --git a/src/dev/build/build_distributables.js b/src/dev/build/build_distributables.js index 2ea71fa2c1d335..22a348b78dc0ae 100644 --- a/src/dev/build/build_distributables.js +++ b/src/dev/build/build_distributables.js @@ -20,16 +20,16 @@ import { getConfig, createRunner } from './lib'; import { + BuildKibanaPlatformPluginsTask, BuildPackagesTask, CleanClientModulesOnDLLTask, CleanEmptyFoldersTask, CleanExtraBinScriptsTask, - CleanExtraBrowsersTask, CleanExtraFilesFromModulesTask, - CleanPackagesTask, - CleanTypescriptTask, CleanNodeBuildsTask, + CleanPackagesTask, CleanTask, + CleanTypescriptTask, CopyBinScriptsTask, CopySourceTask, CreateArchivesSourcesTask, @@ -44,20 +44,20 @@ import { CreateRpmPackageTask, DownloadNodeBuildsTask, ExtractNodeBuildsTask, + InstallChromiumTask, InstallDependenciesTask, - BuildKibanaPlatformPluginsTask, OptimizeBuildTask, PatchNativeModulesTask, + PathLengthTask, RemovePackageJsonDepsTask, RemoveWorkspacesTask, TranspileBabelTask, TranspileScssTask, UpdateLicenseFileTask, + UuidVerificationTask, VerifyEnvTask, VerifyExistingNodeBuildsTask, - PathLengthTask, WriteShaSumsTask, - UuidVerificationTask, } from './tasks'; export async function buildDistributables(options) { @@ -134,12 +134,12 @@ export async function buildDistributables(options) { /** * copy generic build outputs into platform-specific build - * directories and perform platform-specific steps + * directories and perform platform/architecture-specific steps */ await run(CreateArchivesSourcesTask); await run(PatchNativeModulesTask); + await run(InstallChromiumTask); await run(CleanExtraBinScriptsTask); - await run(CleanExtraBrowsersTask); await run(CleanNodeBuildsTask); await run(PathLengthTask); diff --git a/src/dev/build/lib/__tests__/config.js b/src/dev/build/lib/__tests__/config.js index d2f408378da25a..9544fc84dc6ff5 100644 --- a/src/dev/build/lib/__tests__/config.js +++ b/src/dev/build/lib/__tests__/config.js @@ -72,15 +72,31 @@ describe('dev/build/lib/config', () => { }); }); + describe('#getPlatform()', () => { + it('throws error when platform does not exist', async () => { + const { config } = await setup(); + const fn = () => config.getPlatform('foo', 'x64'); + + expect(fn).to.throwException(/Unable to find platform/); + }); + + it('throws error when architecture does not exist', async () => { + const { config } = await setup(); + const fn = () => config.getPlatform('linux', 'foo'); + + expect(fn).to.throwException(/Unable to find platform/); + }); + }); + describe('#getTargetPlatforms()', () => { it('returns an array of all platform objects', async () => { const { config } = await setup(); expect( config .getTargetPlatforms() - .map((p) => p.getName()) + .map((p) => p.getNodeArch()) .sort() - ).to.eql(['darwin', 'linux', 'windows']); + ).to.eql(['darwin-x64', 'linux-arm64', 'linux-x64', 'win32-x64']); }); it('returns just this platform when targetAllPlatforms = false', async () => { @@ -99,9 +115,9 @@ describe('dev/build/lib/config', () => { expect( config .getTargetPlatforms() - .map((p) => p.getName()) + .map((p) => p.getNodeArch()) .sort() - ).to.eql(['darwin', 'linux', 'windows']); + ).to.eql(['darwin-x64', 'linux-arm64', 'linux-x64', 'win32-x64']); }); it('returns this platform and linux, when targetAllPlatforms = false', async () => { @@ -111,39 +127,20 @@ describe('dev/build/lib/config', () => { if (process.platform !== 'linux') { expect(platforms).to.have.length(2); expect(platforms[0]).to.be(config.getPlatformForThisOs()); - expect(platforms[1]).to.be(config.getLinuxPlatform()); + expect(platforms[1]).to.be(config.getPlatform('linux', 'x64')); } else { expect(platforms).to.have.length(1); - expect(platforms[0]).to.be(config.getLinuxPlatform()); + expect(platforms[0]).to.be(config.getPlatform('linux', 'x64')); } }); }); - describe('#getLinuxPlatform()', () => { - it('returns the linux platform', async () => { - const { config } = await setup(); - expect(config.getLinuxPlatform().getName()).to.be('linux'); - }); - }); - - describe('#getWindowsPlatform()', () => { - it('returns the windows platform', async () => { - const { config } = await setup(); - expect(config.getWindowsPlatform().getName()).to.be('windows'); - }); - }); - - describe('#getMacPlatform()', () => { - it('returns the mac platform', async () => { - const { config } = await setup(); - expect(config.getMacPlatform().getName()).to.be('darwin'); - }); - }); - describe('#getPlatformForThisOs()', () => { it('returns the platform that matches the arch of this machine', async () => { const { config } = await setup(); - expect(config.getPlatformForThisOs().getName()).to.be(process.platform); + const currentPlatform = config.getPlatformForThisOs(); + expect(currentPlatform.getName()).to.be(process.platform); + expect(currentPlatform.getArchitecture()).to.be(process.arch); }); }); diff --git a/src/dev/build/lib/__tests__/platform.js b/src/dev/build/lib/__tests__/platform.js index 86ef1749feca90..a7bb5670ee4123 100644 --- a/src/dev/build/lib/__tests__/platform.js +++ b/src/dev/build/lib/__tests__/platform.js @@ -30,37 +30,39 @@ describe('src/dev/build/lib/platform', () => { describe('getNodeArch()', () => { it('returns the node arch for the passed name', () => { - expect(createPlatform('windows').getNodeArch()).to.be('windows-x64'); + expect(createPlatform('win32', 'x64').getNodeArch()).to.be('win32-x64'); }); }); describe('getBuildName()', () => { it('returns the build name for the passed name', () => { - expect(createPlatform('windows').getBuildName()).to.be('windows-x86_64'); + expect(createPlatform('linux', 'arm64', 'linux-aarch64').getBuildName()).to.be( + 'linux-aarch64' + ); }); }); describe('isWindows()', () => { - it('returns true if name is windows', () => { - expect(createPlatform('windows').isWindows()).to.be(true); - expect(createPlatform('linux').isWindows()).to.be(false); - expect(createPlatform('darwin').isWindows()).to.be(false); + it('returns true if name is win32', () => { + expect(createPlatform('win32', 'x64').isWindows()).to.be(true); + expect(createPlatform('linux', 'x64').isWindows()).to.be(false); + expect(createPlatform('darwin', 'x64').isWindows()).to.be(false); }); }); describe('isLinux()', () => { it('returns true if name is linux', () => { - expect(createPlatform('windows').isLinux()).to.be(false); - expect(createPlatform('linux').isLinux()).to.be(true); - expect(createPlatform('darwin').isLinux()).to.be(false); + expect(createPlatform('win32', 'x64').isLinux()).to.be(false); + expect(createPlatform('linux', 'x64').isLinux()).to.be(true); + expect(createPlatform('darwin', 'x64').isLinux()).to.be(false); }); }); describe('isMac()', () => { it('returns true if name is darwin', () => { - expect(createPlatform('windows').isMac()).to.be(false); - expect(createPlatform('linux').isMac()).to.be(false); - expect(createPlatform('darwin').isMac()).to.be(true); + expect(createPlatform('win32', 'x64').isMac()).to.be(false); + expect(createPlatform('linux', 'x64').isMac()).to.be(false); + expect(createPlatform('darwin', 'x64').isMac()).to.be(true); }); }); }); diff --git a/src/dev/build/lib/config.js b/src/dev/build/lib/config.js index cd762d9bb1f203..36621f1c2d4ace 100644 --- a/src/dev/build/lib/config.js +++ b/src/dev/build/lib/config.js @@ -18,7 +18,7 @@ */ import { dirname, resolve, relative } from 'path'; -import { platform as getOsPlatform } from 'os'; +import os from 'os'; import { getVersionInfo } from './version_info'; import { createPlatform } from './platform'; @@ -29,7 +29,12 @@ export async function getConfig({ isRelease, targetAllPlatforms, versionQualifie const repoRoot = dirname(pkgPath); const nodeVersion = pkg.engines.node; - const platforms = ['darwin', 'linux', 'windows'].map(createPlatform); + const platforms = [ + createPlatform('linux', 'x64', 'linux-x86_64'), + createPlatform('linux', 'arm64', 'linux-aarch64'), + createPlatform('darwin', 'x64', 'darwin-x86_64'), + createPlatform('win32', 'x64', 'windows-x86_64'), + ]; const versionInfo = await getVersionInfo({ isRelease, @@ -101,34 +106,22 @@ export async function getConfig({ isRelease, targetAllPlatforms, versionQualifie } if (process.platform === 'linux') { - return [this.getLinuxPlatform()]; + return [this.getPlatform('linux', 'x64')]; } - return [this.getPlatformForThisOs(), this.getLinuxPlatform()]; + return [this.getPlatformForThisOs(), this.getPlatform('linux', 'x64')]; } - /** - * Get the linux platform object - * @return {Platform} - */ - getLinuxPlatform() { - return platforms.find((p) => p.isLinux()); - } + getPlatform(name, arch) { + const selected = platforms.find((p) => { + return name === p.getName() && arch === p.getArchitecture(); + }); - /** - * Get the windows platform object - * @return {Platform} - */ - getWindowsPlatform() { - return platforms.find((p) => p.isWindows()); - } + if (!selected) { + throw new Error(`Unable to find platform (${name}) with architecture (${arch})`); + } - /** - * Get the mac platform object - * @return {Platform} - */ - getMacPlatform() { - return platforms.find((p) => p.isMac()); + return selected; } /** @@ -136,16 +129,7 @@ export async function getConfig({ isRelease, targetAllPlatforms, versionQualifie * @return {Platform} */ getPlatformForThisOs() { - switch (getOsPlatform()) { - case 'darwin': - return this.getMacPlatform(); - case 'win32': - return this.getWindowsPlatform(); - case 'linux': - return this.getLinuxPlatform(); - default: - throw new Error(`Unable to find platform for this os`); - } + return this.getPlatform(os.platform(), os.arch()); } /** diff --git a/src/dev/build/lib/platform.js b/src/dev/build/lib/platform.js index ac2faa7cbdf85c..ab2672615e1c53 100644 --- a/src/dev/build/lib/platform.js +++ b/src/dev/build/lib/platform.js @@ -17,22 +17,26 @@ * under the License. */ -export function createPlatform(name) { +export function createPlatform(name, architecture, buildName) { return new (class Platform { getName() { return name; } - getNodeArch() { - return `${name}-x64`; + getArchitecture() { + return architecture; } getBuildName() { - return `${name}-x86_64`; + return buildName; + } + + getNodeArch() { + return `${name}-${architecture}`; } isWindows() { - return name === 'windows'; + return name === 'win32'; } isMac() { diff --git a/src/dev/build/tasks/clean_tasks.js b/src/dev/build/tasks/clean_tasks.js index 31731e392e5cb4..ff5c3b3a73dd3c 100644 --- a/src/dev/build/tasks/clean_tasks.js +++ b/src/dev/build/tasks/clean_tasks.js @@ -201,45 +201,6 @@ export const CleanExtraBinScriptsTask = { }, }; -export const CleanExtraBrowsersTask = { - description: 'Cleaning extra browsers from platform-specific builds', - - async run(config, log, build) { - const getBrowserPathsForPlatform = (platform) => { - const reportingDir = 'x-pack/plugins/reporting'; - const chromiumDir = '.chromium'; - const chromiumPath = (p) => - build.resolvePathForPlatform(platform, reportingDir, chromiumDir, p); - return (platforms) => { - const paths = []; - if (platforms.windows) { - paths.push(chromiumPath('chromium-*-win32.zip')); - paths.push(chromiumPath('chromium-*-windows.zip')); - } - - if (platforms.darwin) { - paths.push(chromiumPath('chromium-*-darwin.zip')); - } - - if (platforms.linux) { - paths.push(chromiumPath('chromium-*-linux.zip')); - } - return paths; - }; - }; - for (const platform of config.getNodePlatforms()) { - const getBrowserPaths = getBrowserPathsForPlatform(platform); - if (platform.isWindows()) { - await deleteAll(getBrowserPaths({ linux: true, darwin: true }), log); - } else if (platform.isMac()) { - await deleteAll(getBrowserPaths({ linux: true, windows: true }), log); - } else if (platform.isLinux()) { - await deleteAll(getBrowserPaths({ windows: true, darwin: true }), log); - } - } - }, -}; - export const CleanEmptyFoldersTask = { description: 'Cleaning all empty folders recursively', diff --git a/src/dev/build/tasks/create_archives_sources_task.js b/src/dev/build/tasks/create_archives_sources_task.js index 53cf750f484a1b..76f08bd3d2e4f8 100644 --- a/src/dev/build/tasks/create_archives_sources_task.js +++ b/src/dev/build/tasks/create_archives_sources_task.js @@ -33,7 +33,7 @@ export const CreateArchivesSourcesTask = { log.debug( 'Generic build source copied into', - platform.getName(), + platform.getNodeArch(), 'specific build directory' ); @@ -43,7 +43,7 @@ export const CreateArchivesSourcesTask = { destination: build.resolvePathForPlatform(platform, 'node'), }); - log.debug('Node.js copied into', platform.getName(), 'specific build directory'); + log.debug('Node.js copied into', platform.getNodeArch(), 'specific build directory'); }) ); }, diff --git a/src/dev/build/tasks/index.js b/src/dev/build/tasks/index.js index be675b4aa6ca4d..d96e745c107762 100644 --- a/src/dev/build/tasks/index.js +++ b/src/dev/build/tasks/index.js @@ -18,6 +18,7 @@ */ export * from './bin'; +export * from './build_kibana_platform_plugins'; export * from './build_packages_task'; export * from './clean_tasks'; export * from './copy_source_task'; @@ -26,18 +27,18 @@ export * from './create_archives_task'; export * from './create_empty_dirs_and_files_task'; export * from './create_package_json_task'; export * from './create_readme_task'; +export * from './install_chromium'; export * from './install_dependencies_task'; export * from './license_file_task'; -export * from './nodejs'; export * from './nodejs_modules'; +export * from './nodejs'; export * from './notice_file_task'; export * from './optimize_task'; export * from './os_packages'; export * from './patch_native_modules_task'; +export * from './path_length_task'; export * from './transpile_babel_task'; export * from './transpile_scss_task'; +export * from './uuid_verification_task'; export * from './verify_env_task'; export * from './write_sha_sums_task'; -export * from './path_length_task'; -export * from './build_kibana_platform_plugins'; -export * from './uuid_verification_task'; diff --git a/src/dev/build/tasks/install_chromium.js b/src/dev/build/tasks/install_chromium.js new file mode 100644 index 00000000000000..c5878b23d43ae1 --- /dev/null +++ b/src/dev/build/tasks/install_chromium.js @@ -0,0 +1,44 @@ +/* + * 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. + */ + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { installBrowser } from '../../../../x-pack/plugins/reporting/server/browsers/install'; +import { first } from 'rxjs/operators'; + +export const InstallChromiumTask = { + description: 'Installing Chromium', + + async run(config, log, build) { + if (build.isOss()) { + return; + } else { + for (const platform of config.getNodePlatforms()) { + log.info(`Installing Chromium for ${platform.getName()}-${platform.getArchitecture()}`); + + const { binaryPath$ } = installBrowser( + log, + build.resolvePathForPlatform(platform, 'x-pack/plugins/reporting/chromium'), + platform.getName(), + platform.getArchitecture() + ); + await binaryPath$.pipe(first()).toPromise(); + } + } + }, +}; diff --git a/src/dev/build/tasks/notice_file_task.js b/src/dev/build/tasks/notice_file_task.js index 36a92f59314e2c..59369c7cb5a3b7 100644 --- a/src/dev/build/tasks/notice_file_task.js +++ b/src/dev/build/tasks/notice_file_task.js @@ -47,7 +47,7 @@ export const CreateNoticeFileTask = { log.info('Generating build notice'); const { extractDir: nodeDir, version: nodeVersion } = getNodeDownloadInfo( config, - config.getLinuxPlatform() + config.getPlatform('linux', 'x64') ); const notice = await generateBuildNoticeText({ diff --git a/src/dev/build/tasks/os_packages/run_fpm.js b/src/dev/build/tasks/os_packages/run_fpm.js index 0496bcf08fb91f..eb77da0e701760 100644 --- a/src/dev/build/tasks/os_packages/run_fpm.js +++ b/src/dev/build/tasks/os_packages/run_fpm.js @@ -22,7 +22,7 @@ import { resolve } from 'path'; import { exec } from '../../lib'; export async function runFpm(config, log, build, type, pkgSpecificFlags) { - const linux = config.getLinuxPlatform(); + const linux = config.getPlatform('linux', 'x64'); const version = config.getBuildVersion(); const resolveWithTrailingSlash = (...paths) => `${resolve(...paths)}/`; diff --git a/src/dev/build/tasks/patch_native_modules_task.js b/src/dev/build/tasks/patch_native_modules_task.js index fba33442fad103..a10010ed5255ff 100644 --- a/src/dev/build/tasks/patch_native_modules_task.js +++ b/src/dev/build/tasks/patch_native_modules_task.js @@ -38,7 +38,7 @@ const packages = [ url: 'https://github.com/uhop/node-re2/releases/download/1.14.0/linux-x64-64.gz', sha256: 'f54f059035e71a7ccb3fa201080e260c41d228d13a8247974b4bb157691b6757', }, - windows: { + win32: { url: 'https://github.com/uhop/node-re2/releases/download/1.14.0/win32-x64-64.gz', sha256: 'de708446a8b802f4634c2cfef097c2625a2811fdcd8133dfd7b7c485f966caa9', }, diff --git a/x-pack/.gitignore b/x-pack/.gitignore index 68262c4bf734ba..e181caf2b1a494 100644 --- a/x-pack/.gitignore +++ b/x-pack/.gitignore @@ -8,7 +8,7 @@ /test/reporting/configs/failure_debug/ /legacy/plugins/reporting/.chromium/ /legacy/plugins/reporting/.phantom/ -/plugins/reporting/.chromium/ +/plugins/reporting/chromium/ /plugins/reporting/.phantom/ /.aws-config.json /.env diff --git a/x-pack/build_chromium/README.md b/x-pack/build_chromium/README.md index 72e41afc80c95e..ce7e110a5f9140 100644 --- a/x-pack/build_chromium/README.md +++ b/x-pack/build_chromium/README.md @@ -20,7 +20,8 @@ You'll need access to our GCP account, which is where we have two machines provi Chromium is built via a build tool called "ninja". The build can be configured by specifying build flags either in an "args.gn" file or via commandline args. We have an "args.gn" file per platform: - mac: darwin/args.gn -- linux: linux/args.gn +- linux 64bit: linux-x64/args.gn +- ARM 64bit: linux-aarch64/args.gn - windows: windows/args.gn The various build flags are not well documented. Some are documented [here](https://www.chromium.org/developers/gn-build-configuration). Some, such as `enable_basic_printing = false`, I only found by poking through 3rd party build scripts. @@ -65,15 +66,16 @@ Create the build folder: Copy the `x-pack/build-chromium` folder to each. Replace `you@your-machine` with the correct username and VM name: -- Mac: `cp -r ~/dev/elastic/kibana/x-pack/build_chromium ~/chromium/build_chromium` -- Linux: `gcloud compute scp --recurse ~/dev/elastic/kibana/x-pack/build_chromium you@your-machine:~/chromium/build_chromium --zone=us-east1-b` +- Mac: `cp -r x-pack/build_chromium ~/chromium/build_chromium` +- Linux: `gcloud compute scp --recurse x-pack/build_chromium you@your-machine:~/chromium/ --zone=us-east1-b --project "XXXXXXXX"` - Windows: Copy the `build_chromium` folder via the RDP GUI into `c:\chromium\build_chromium` There is an init script for each platform. This downloads and installs the necessary prerequisites, sets environment variables, etc. -- Mac: `~/chromium/build_chromium/darwin/init.sh` -- Linux: `~/chromium/build_chromium/linux/init.sh` -- Windows `c:\chromium\build_chromium\windows\init.bat` +- Mac x64: `~/chromium/build_chromium/darwin/init.sh` +- Linux x64: `~/chromium/build_chromium/linux/init.sh` +- Linux arm64: `~/chromium/build_chromium/linux/init.sh arm64` +- Windows x64: `c:\chromium\build_chromium\windows\init.bat` In windows, at least, you will need to do a number of extra steps: @@ -102,15 +104,16 @@ Note: In Linux, you should run the build command in tmux so that if your ssh ses To run the build, replace the sha in the following commands with the sha that you wish to build: -- Mac: `python ~/chromium/build_chromium/build.py 312d84c8ce62810976feda0d3457108a6dfff9e6` -- Linux: `python ~/chromium/build_chromium/build.py 312d84c8ce62810976feda0d3457108a6dfff9e6` -- Windows: `python c:\chromium\build_chromium\build.py 312d84c8ce62810976feda0d3457108a6dfff9e6` +- Mac x64: `python ~/chromium/build_chromium/build.py 312d84c8ce62810976feda0d3457108a6dfff9e6` +- Linux x64: `python ~/chromium/build_chromium/build.py 312d84c8ce62810976feda0d3457108a6dfff9e6` +- Linux arm64: `python ~/chromium/build_chromium/build.py 312d84c8ce62810976feda0d3457108a6dfff9e6 arm64` +- Windows x64: `python c:\chromium\build_chromium\build.py 312d84c8ce62810976feda0d3457108a6dfff9e6` ## Artifacts -After the build completes, there will be a .zip file and a .md5 file in `~/chromium/chromium/src/out/headless`. These are named like so: `chromium-{first_7_of_SHA}-{platform}`, for example: `chromium-4747cc2-linux`. +After the build completes, there will be a .zip file and a .md5 file in `~/chromium/chromium/src/out/headless`. These are named like so: `chromium-{first_7_of_SHA}-{platform}-{arch}`, for example: `chromium-4747cc2-linux-x64`. -The zip files need to be deployed to s3. For testing, I drop them into `headless-shell-dev`, but for production, they need to be in `headless-shell`. And the `x-pack/plugins/reporting/server/browsers/chromium/paths.ts` file needs to be upated to have the correct `archiveChecksum`, `archiveFilename`, `binaryChecksum` and `baseUrl`. Below is a list of what the archive's are: +The zip files need to be deployed to GCP Storage. For testing, I drop them into `headless-shell-dev`, but for production, they need to be in `headless-shell`. And the `x-pack/plugins/reporting/server/browsers/chromium/paths.ts` file needs to be upated to have the correct `archiveChecksum`, `archiveFilename`, `binaryChecksum` and `baseUrl`. Below is a list of what the archive's are: - `archiveChecksum`: The contents of the `.md5` file, which is the `md5` checksum of the zip file. - `binaryChecksum`: The `md5` checksum of the `headless_shell` binary itself. @@ -139,8 +142,8 @@ In the case of Windows, you can use IE to open `http://localhost:9221` and see i The following links provide helpful context about how the Chromium build works, and its prerequisites: - https://www.chromium.org/developers/how-tos/get-the-code/working-with-release-branches -- https://chromium.googlesource.com/chromium/src/+/master/docs/windows_build_instructions.md -- https://chromium.googlesource.com/chromium/src/+/master/docs/mac_build_instructions.md -- https://chromium.googlesource.com/chromium/src/+/master/docs/linux_build_instructions.md +- https://chromium.googlesource.com/chromium/src/+/HEAD/docs/windows_build_instructions.md +- https://chromium.googlesource.com/chromium/src/+/HEAD/docs/mac_build_instructions.md +- https://chromium.googlesource.com/chromium/src/+/HEAD/docs/linux/build_instructions.md - Some build-flag descriptions: https://www.chromium.org/developers/gn-build-configuration - The serverless Chromium project was indispensable: https://github.com/adieuadieu/serverless-chrome/blob/b29445aa5a96d031be2edd5d1fc8651683bf262c/packages/lambda/builds/chromium/build/build.sh diff --git a/x-pack/build_chromium/build.py b/x-pack/build_chromium/build.py index 82b0561fdcfe1c..52ba325d6f7268 100644 --- a/x-pack/build_chromium/build.py +++ b/x-pack/build_chromium/build.py @@ -17,7 +17,10 @@ # 4747cc23ae334a57a35ed3c8e6adcdbc8a50d479 source_version = sys.argv[1] -print('Building Chromium ' + source_version) +# Set to "arm" to build for ARM on Linux +arch_name = sys.argv[2] if len(sys.argv) >= 3 else 'x64' + +print('Building Chromium ' + source_version + ' for ' + arch_name) # Set the environment variables required by the build tools print('Configuring the build environment') @@ -42,21 +45,29 @@ print('Copying build args: ' + platform_build_args + ' to out/headless/args.gn') mkdir('out/headless') shutil.copyfile(platform_build_args, 'out/headless/args.gn') + +print('Adding target_cpu to args') + +f = open('out/headless/args.gn', 'a') +f.write('\rtarget_cpu = "' + arch_name + '"') +f.close() + runcmd('gn gen out/headless') # Build Chromium... this takes *forever* on underpowered VMs print('Compiling... this will take a while') runcmd('autoninja -C out/headless headless_shell') -# Optimize the output on Linux and Mac by stripping inessentials from the binary -if platform.system() != 'Windows': +# Optimize the output on Linux x64 and Mac by stripping inessentials from the binary +# ARM must be cross-compiled from Linux and can not read the ARM binary in order to strip +if platform.system() != 'Windows' and arch_name != 'arm64': print('Optimizing headless_shell') shutil.move('out/headless/headless_shell', 'out/headless/headless_shell_raw') runcmd('strip -o out/headless/headless_shell out/headless/headless_shell_raw') # Create the zip and generate the md5 hash using filenames like: -# chromium-4747cc2-linux.zip -base_filename = 'out/headless/chromium-' + source_version[:7].strip('.') + '-' + platform.system().lower() +# chromium-4747cc2-linux_x64.zip +base_filename = 'out/headless/chromium-' + source_version[:7].strip('.') + '-' + platform.system().lower() + '_' + arch_name zip_filename = base_filename + '.zip' md5_filename = base_filename + '.md5' @@ -66,7 +77,7 @@ def archive_file(name): """A little helper function to write individual files to the zip file""" from_path = os.path.join('out/headless', name) - to_path = os.path.join('headless_shell-' + platform.system().lower(), name) + to_path = os.path.join('headless_shell-' + platform.system().lower() + '_' + arch_name, name) archive.write(from_path, to_path) # Each platform has slightly different requirements for what dependencies @@ -76,6 +87,9 @@ def archive_file(name): archive_file(os.path.join('swiftshader', 'libEGL.so')) archive_file(os.path.join('swiftshader', 'libGLESv2.so')) + if arch_name == 'arm64': + archive_file(os.path.join('swiftshader', 'libEGL.so')) + elif platform.system() == 'Windows': archive_file('headless_shell.exe') archive_file('dbghelp.dll') diff --git a/x-pack/build_chromium/init.py b/x-pack/build_chromium/init.py index a3c5f8dc16fb75..f543922f7653a2 100644 --- a/x-pack/build_chromium/init.py +++ b/x-pack/build_chromium/init.py @@ -1,4 +1,4 @@ -import os, platform +import os, platform, sys from build_util import runcmd, mkdir, md5_file, root_dir, configure_environment # This is a cross-platform initialization script which should only be run @@ -29,4 +29,10 @@ # Build Linux deps if platform.system() == 'Linux': os.chdir('src') + + if len(sys.argv) >= 2: + sysroot_cmd = 'build/linux/sysroot_scripts/install-sysroot.py --arch=' + sys.argv[1] + print('Running `' + sysroot_cmd + '`') + runcmd(sysroot_cmd) + runcmd('build/install-build-deps.sh') diff --git a/x-pack/build_chromium/linux/init.sh b/x-pack/build_chromium/linux/init.sh index e259ebded12a18..83cc4a8e5d4d5a 100755 --- a/x-pack/build_chromium/linux/init.sh +++ b/x-pack/build_chromium/linux/init.sh @@ -10,4 +10,4 @@ fi # Launch the cross-platform init script using a relative path # from this script's location. -python "`dirname "$0"`/../init.py" +python "`dirname "$0"`/../init.py" $1 diff --git a/x-pack/gulpfile.js b/x-pack/gulpfile.js index 0118d178f54e57..adccaccecd7da0 100644 --- a/x-pack/gulpfile.js +++ b/x-pack/gulpfile.js @@ -9,13 +9,11 @@ require('../src/setup_node_env'); const { buildTask } = require('./tasks/build'); const { devTask } = require('./tasks/dev'); const { testTask, testKarmaTask, testKarmaDebugTask } = require('./tasks/test'); -const { prepareTask } = require('./tasks/prepare'); // export the tasks that are runnable from the CLI module.exports = { build: buildTask, dev: devTask, - prepare: prepareTask, test: testTask, 'test:karma': testKarmaTask, 'test:karma:debug': testKarmaDebugTask, diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/args.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/args.ts index 928f3b83778090..4f4b41fe0545f8 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/args.ts +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/args.ts @@ -55,10 +55,6 @@ export const args = ({ userDataDir, viewport, disableSandbox, proxy: proxyConfig flags.push('--no-sandbox'); } - // log to chrome_debug.log - flags.push('--enable-logging'); - flags.push('--v=1'); - if (process.platform === 'linux') { flags.push('--disable-setuid-sandbox'); } diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts index 3ce5329e425174..157d109e9e27e2 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts @@ -24,7 +24,6 @@ import { CaptureConfig } from '../../../../server/types'; import { LevelLogger } from '../../../lib'; import { safeChildProcess } from '../../safe_child_process'; import { HeadlessChromiumDriver } from '../driver'; -import { getChromeLogLocation } from '../paths'; import { puppeteerLaunch } from '../puppeteer'; import { args } from './args'; @@ -77,7 +76,6 @@ export class HeadlessChromiumDriverFactory { `The Reporting plugin encountered issues launching Chromium in a self-test. You may have trouble generating reports.` ); logger.error(error); - logger.warning(`See Chromium's log output at "${getChromeLogLocation(this.binaryPath)}"`); return null; }); } diff --git a/x-pack/plugins/reporting/server/browsers/chromium/paths.ts b/x-pack/plugins/reporting/server/browsers/chromium/paths.ts index 1e760c081f989e..c22db895b451e0 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/paths.ts +++ b/x-pack/plugins/reporting/server/browsers/chromium/paths.ts @@ -7,11 +7,12 @@ import path from 'path'; export const paths = { - archivesPath: path.resolve(__dirname, '../../../.chromium'), + archivesPath: path.resolve(__dirname, '../../../../../../.chromium'), baseUrl: 'https://storage.googleapis.com/headless_shell/', packages: [ { platforms: ['darwin', 'freebsd', 'openbsd'], + architecture: 'x64', archiveFilename: 'chromium-312d84c-darwin.zip', archiveChecksum: '020303e829745fd332ae9b39442ce570', binaryChecksum: '5cdec11d45a0eddf782bed9b9f10319f', @@ -19,13 +20,23 @@ export const paths = { }, { platforms: ['linux'], + architecture: 'x64', archiveFilename: 'chromium-312d84c-linux.zip', archiveChecksum: '15ba9166a42f93ee92e42217b737018d', binaryChecksum: 'c7fe36ed3e86a6dd23323be0a4e8c0fd', binaryRelativePath: 'headless_shell-linux/headless_shell', }, + { + platforms: ['linux'], + architecture: 'arm64', + archiveFilename: 'chromium-312d84c-linux_arm64.zip', + archiveChecksum: 'aa4d5b99dd2c1bd8e614e67f63a48652', + binaryChecksum: '7fdccff319396f0aee7f269dd85fe6fc', + binaryRelativePath: 'headless_shell-linux_arm64/headless_shell', + }, { platforms: ['win32'], + architecture: 'x64', archiveFilename: 'chromium-312d84c-windows.zip', archiveChecksum: '3e36adfb755dacacc226ed5fd6b43105', binaryChecksum: '9913e431fbfc7dfcd958db74ace4d58b', @@ -33,6 +44,3 @@ export const paths = { }, ], }; - -export const getChromeLogLocation = (binaryPath: string) => - path.join(binaryPath, '..', 'chrome_debug.log'); diff --git a/x-pack/plugins/reporting/server/browsers/download/clean.ts b/x-pack/plugins/reporting/server/browsers/download/clean.ts index 8558b001e8174f..1a362be8568cdd 100644 --- a/x-pack/plugins/reporting/server/browsers/download/clean.ts +++ b/x-pack/plugins/reporting/server/browsers/download/clean.ts @@ -29,7 +29,7 @@ export async function clean(dir: string, expectedPaths: string[], logger: LevelL await asyncMap(filenames, async (filename) => { const path = resolvePath(dir, filename); if (!expectedPaths.includes(path)) { - logger.warn(`Deleting unexpected file ${path}`); + logger.warning(`Deleting unexpected file ${path}`); await del(path, { force: true }); } }); diff --git a/x-pack/plugins/reporting/server/browsers/download/ensure_downloaded.ts b/x-pack/plugins/reporting/server/browsers/download/ensure_downloaded.ts index add14448e2f1d3..f56af15f5d76b2 100644 --- a/x-pack/plugins/reporting/server/browsers/download/ensure_downloaded.ts +++ b/x-pack/plugins/reporting/server/browsers/download/ensure_downloaded.ts @@ -7,7 +7,6 @@ import { existsSync } from 'fs'; import { resolve as resolvePath } from 'path'; import { BrowserDownload, chromium } from '../'; -import { BROWSER_TYPE } from '../../../common/constants'; import { LevelLogger } from '../../lib'; import { md5 } from './checksum'; import { clean } from './clean'; @@ -17,19 +16,9 @@ import { asyncMap } from './util'; /** * Check for the downloaded archive of each requested browser type and * download them if they are missing or their checksum is invalid - * @param {String} browserType * @return {Promise} */ -export async function ensureBrowserDownloaded(browserType = BROWSER_TYPE, logger: LevelLogger) { - await ensureDownloaded([chromium], logger); -} - -/** - * Check for the downloaded archive of each requested browser type and - * download them if they are missing or their checksum is invalid* - * @return {Promise} - */ -export async function ensureAllBrowsersDownloaded(logger: LevelLogger) { +export async function ensureBrowserDownloaded(logger: LevelLogger) { await ensureDownloaded([chromium], logger); } diff --git a/x-pack/plugins/reporting/server/browsers/download/index.ts b/x-pack/plugins/reporting/server/browsers/download/index.ts index bf7ed450b462fc..83acec4e0e0b58 100644 --- a/x-pack/plugins/reporting/server/browsers/download/index.ts +++ b/x-pack/plugins/reporting/server/browsers/download/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { ensureBrowserDownloaded, ensureAllBrowsersDownloaded } from './ensure_downloaded'; +export { ensureBrowserDownloaded } from './ensure_downloaded'; diff --git a/x-pack/plugins/reporting/server/browsers/index.ts b/x-pack/plugins/reporting/server/browsers/index.ts index be5b869ba523b7..0cfe36f6a76560 100644 --- a/x-pack/plugins/reporting/server/browsers/index.ts +++ b/x-pack/plugins/reporting/server/browsers/index.ts @@ -12,7 +12,6 @@ import { HeadlessChromiumDriverFactory } from './chromium/driver_factory'; import { installBrowser } from './install'; import { ReportingConfig } from '..'; -export { ensureAllBrowsersDownloaded } from './download'; export { HeadlessChromiumDriver } from './chromium/driver'; export { HeadlessChromiumDriverFactory } from './chromium/driver_factory'; export { chromium } from './chromium'; @@ -42,7 +41,7 @@ export const initializeBrowserDriverFactory = async ( config: ReportingConfig, logger: LevelLogger ) => { - const { binaryPath$ } = installBrowser(chromium, config, logger); + const { binaryPath$ } = installBrowser(logger); const binaryPath = await binaryPath$.pipe(first()).toPromise(); const captureConfig = config.get('capture'); return chromium.createDriverFactory(binaryPath, captureConfig, logger); diff --git a/x-pack/plugins/reporting/server/browsers/install.ts b/x-pack/plugins/reporting/server/browsers/install.ts index 49361b7b6014dc..9eddbe5ef04984 100644 --- a/x-pack/plugins/reporting/server/browsers/install.ts +++ b/x-pack/plugins/reporting/server/browsers/install.ts @@ -4,24 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import fs from 'fs'; +import os from 'os'; import path from 'path'; +import del from 'del'; + import * as Rx from 'rxjs'; -import { first } from 'rxjs/operators'; -import { promisify } from 'util'; -import { ReportingConfig } from '../'; import { LevelLogger } from '../lib'; -import { BrowserDownload } from './'; import { ensureBrowserDownloaded } from './download'; // @ts-ignore import { md5 } from './download/checksum'; // @ts-ignore import { extract } from './extract'; - -const chmod = promisify(fs.chmod); +import { paths } from './chromium/paths'; interface Package { platforms: string[]; + architecture: string; } /** @@ -29,44 +27,33 @@ interface Package { * archive. If there is an error extracting the archive an `ExtractError` is thrown */ export function installBrowser( - browser: BrowserDownload, - config: ReportingConfig, - logger: LevelLogger + logger: LevelLogger, + chromiumPath: string = path.resolve(__dirname, '../../chromium'), + platform: string = process.platform, + architecture: string = os.arch() ): { binaryPath$: Rx.Subject } { const binaryPath$ = new Rx.Subject(); const backgroundInstall = async () => { - const captureConfig = config.get('capture'); - const { autoDownload, type: browserType } = captureConfig.browser; - if (autoDownload) { - await ensureBrowserDownloaded(browserType, logger); - } + const pkg = paths.packages.find((p: Package) => { + return p.platforms.includes(platform) && p.architecture === architecture; + }); - const pkg = browser.paths.packages.find((p: Package) => p.platforms.includes(process.platform)); if (!pkg) { - throw new Error(`Unsupported platform: ${JSON.stringify(browser, null, 2)}`); + // TODO: validate this + throw new Error(`Unsupported platform: ${platform}-${architecture}`); } - const dataDir = await config.kbnConfig.get('path', 'data').pipe(first()).toPromise(); - const binaryPath = path.join(dataDir, pkg.binaryRelativePath); + const binaryPath = path.join(chromiumPath, pkg.binaryRelativePath); + const binaryChecksum = await md5(binaryPath).catch(() => ''); - try { - const binaryChecksum = await md5(binaryPath).catch(() => ''); + if (binaryChecksum !== pkg.binaryChecksum) { + await ensureBrowserDownloaded(logger); - if (binaryChecksum !== pkg.binaryChecksum) { - const archive = path.join(browser.paths.archivesPath, pkg.archiveFilename); - logger.info(`Extracting [${archive}] to [${binaryPath}]`); - await extract(archive, dataDir); - await chmod(binaryPath, '755'); - } - } catch (error) { - if (error.cause && ['EACCES', 'EEXIST'].includes(error.cause.code)) { - logger.error( - `Error code ${error.cause.code}: Insufficient permissions for extracting the browser archive. ` + - `Make sure the Kibana data directory (path.data) is owned by the same user that is running Kibana.` - ); - } + const archive = path.join(paths.archivesPath, pkg.archiveFilename); + logger.info(`Extracting [${archive}] to [${binaryPath}]`); - throw error; // reject the promise with the original error + await del(chromiumPath); + await extract(archive, chromiumPath); } logger.debug(`Browser executable: ${binaryPath}`); diff --git a/x-pack/tasks/build.ts b/x-pack/tasks/build.ts index 8dbf6d212d5d29..ce1c9d7c269b72 100644 --- a/x-pack/tasks/build.ts +++ b/x-pack/tasks/build.ts @@ -16,7 +16,6 @@ import fancyLog from 'fancy-log'; import chalk from 'chalk'; import { generateNoticeFromSource } from '../../src/dev/notice'; -import { prepareTask } from './prepare'; import { gitInfo } from './helpers/git_info'; import { PKG_NAME } from './helpers/pkg'; import { BUILD_VERSION } from './helpers/build_version'; @@ -78,7 +77,6 @@ async function generateNoticeText() { export const buildTask = gulp.series( cleanBuildTask, reportTask, - prepareTask, buildCanvasShareableRuntime, pluginHelpersBuild, generateNoticeText diff --git a/x-pack/tasks/dev.ts b/x-pack/tasks/dev.ts index f43b67e2885612..c454817158700d 100644 --- a/x-pack/tasks/dev.ts +++ b/x-pack/tasks/dev.ts @@ -7,9 +7,7 @@ import * as pluginHelpers from '@kbn/plugin-helpers'; import gulp from 'gulp'; -import { prepareTask } from './prepare'; - -export const devTask = gulp.series(prepareTask, async function startKibanaServer() { +export const devTask = gulp.series(async function startKibanaServer() { await pluginHelpers.run('start', { flags: process.argv.slice(3), }); diff --git a/x-pack/tasks/prepare.ts b/x-pack/tasks/prepare.ts deleted file mode 100644 index 5c71675d441898..00000000000000 --- a/x-pack/tasks/prepare.ts +++ /dev/null @@ -1,25 +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 { ensureAllBrowsersDownloaded } from '../plugins/reporting/server/browsers'; -import { LevelLogger } from '../plugins/reporting/server/lib'; - -export const prepareTask = async () => { - // eslint-disable-next-line no-console - const consoleLogger = (tag: string) => (message: unknown) => console.log(tag, message); - const innerLogger = { - get: () => innerLogger, - debug: consoleLogger('debug'), - info: consoleLogger('info'), - warn: consoleLogger('warn'), - trace: consoleLogger('trace'), - error: consoleLogger('error'), - fatal: consoleLogger('fatal'), - log: consoleLogger('log'), - }; - const levelLogger = new LevelLogger(innerLogger); - await ensureAllBrowsersDownloaded(levelLogger); -}; From 9537a823ebdb329706974e9ffd415f53231f1730 Mon Sep 17 00:00:00 2001 From: Greg Thompson Date: Thu, 9 Jul 2020 19:51:45 -0700 Subject: [PATCH 39/49] Upgrade EUI to v26.3.1 (#70243) * eui to 26.1.0 * Breadcrumb -> EuiBreadcrumb * keyCode -> key * src snapshot updates * update header variables file location * keyCode -> key in tests * x-pack simple snapshot updates * euitooltip updates * euioverlaymask updates * testbed keycode -> key * fixed header sass mixin * euidatagrid test workaround * euioverlapmask mock * euipaginationbutton markup * nav snapshot update * eui to 26.3.0 * flyout z-index fix * euiflyout snapshot updates * style snapshot updates * table actions have machine readable text * table actions have machine readable text * core api docs update * table actions snapshot update * eui to 26.3.1 * update table actions type * mock issue links; timeout done * breadcrumb -> euibreadcrumb Co-authored-by: Elastic Machine --- package.json | 2 +- packages/kbn-ui-shared-deps/package.json | 2 +- src/core/public/chrome/chrome_service.tsx | 2 +- .../collapsible_nav.test.tsx.snap | 14 +- .../header/__snapshots__/header.test.tsx.snap | 6 +- .../header_breadcrumbs.test.tsx.snap | 21 +- src/core/public/chrome/ui/header/_index.scss | 2 + .../global_toast_list.test.tsx.snap | 1 + src/core/public/public.api.md | 4 +- src/core/public/rendering/_base.scss | 2 +- .../core_plugins/timelion/public/_app.scss | 2 +- .../directives/timelion_expression_input.js | 22 +- .../__tests__/kbn_accessible_click.js | 6 +- .../__tests__/kbn_ui_ace_keyboard_mode.js | 10 +- .../accessibility/kbn_ui_ace_keyboard_mode.js | 6 +- .../exit_full_screen_button.test.js | 4 +- .../management_app/components/form/_form.scss | 2 +- .../console_history/console_history.tsx | 8 +- src/plugins/console/public/styles/_app.scss | 2 +- src/plugins/data/public/public.api.md | 2 +- .../public/use_ui_ace_keyboard_mode.tsx | 6 +- .../create_index_pattern_wizard.test.tsx.snap | 6 + .../components/table/table.test.tsx | 6 +- .../components/table/table.tsx | 8 +- .../__snapshots__/data_view.test.tsx.snap | 4 +- .../public/utils/kbn_accessible_click.js | 6 +- .../exit_full_screen_button.test.tsx | 4 +- .../exit_full_screen_button.tsx | 4 +- .../containers/panel_container.tsx | 10 +- .../objects_table/components/table.test.tsx | 6 +- .../public/components/sidebar/sidebar.tsx | 4 +- .../public/components/metric_vis_value.tsx | 4 +- .../components/color_rules.test.js | 8 +- .../components/vis_editor_visualization.js | 8 +- .../vislib/components/legend/legend.tsx | 10 +- .../vislib/components/legend/legend_item.tsx | 4 +- .../__snapshots__/new_vis_modal.test.tsx.snap | 1914 +++++++++-------- .../plugins/kbn_tp_run_pipeline/package.json | 2 +- .../kbn_sample_panel_action/package.json | 2 +- .../kbn_tp_custom_visualizations/package.json | 2 +- x-pack/dev-tools/jest/setup/polyfills.js | 4 + x-pack/package.json | 2 +- .../List/__test__/List.test.tsx | 6 + .../__test__/__snapshots__/List.test.tsx.snap | 48 +- .../canvas/.storybook/storyshots.test.js | 8 + .../components/autocomplete/autocomplete.js | 18 +- .../workpad_templates.stories.storyshot | 45 +- .../generate_breadcrumbs.ts | 2 +- .../kibana_breadcrumbs/set_breadcrumbs.tsx | 2 +- .../home/data_streams_tab.test.ts | 4 +- .../home/index_templates_tab.test.ts | 4 +- .../__jest__/components/index_table.test.js | 12 +- .../component_template_list.test.ts | 2 +- .../applications/ingest_manager/index.scss | 2 +- .../ingest_pipelines_list.test.ts | 2 +- .../inline_text_input.tsx | 6 +- .../processors_tree/processors_tree.tsx | 4 +- .../upload_license.test.tsx.snap | 26 +- .../upgrade_failure.test.js.snap | 12 +- x-pack/plugins/maps/public/_main.scss | 2 +- .../vector/components/symbol/icon_select.js | 6 +- .../field_title_bar/field_title_bar.test.js | 15 +- .../field_type_icon/field_type_icon.test.js | 3 +- .../components/ml_in_memory_table/types.ts | 2 +- .../group_selector/group_list/group_list.js | 12 +- .../new_group_input/new_group_input.js | 4 +- .../painless_lab/public/styles/_index.scss | 2 +- .../report_listing.test.tsx.snap | 6 + .../report_info_button.test.tsx.snap | 6 +- .../searchprofiler/public/styles/_index.scss | 2 +- .../api_keys_grid_page.test.tsx.snap | 4 +- .../delete_provider/delete_provider.test.tsx | 4 + .../privilege_space_form.tsx | 2 +- .../roles_grid_page.test.tsx.snap | 4 +- .../all_cases/table_filters.test.tsx | 2 +- .../configure_cases/button.test.tsx | 8 +- .../__snapshots__/index.test.tsx.snap | 9 +- .../view/panels/panel_content_utilities.tsx | 4 +- .../note_card_body.test.tsx.snap | 9 +- .../components/open_timeline/index.test.tsx | 6 +- .../open_timeline/search_row/index.test.tsx | 2 +- .../edit_space/manage_space_page.test.tsx | 8 + .../__snapshots__/donut_chart.test.tsx.snap | 3 + .../__snapshots__/expanded_row.test.tsx.snap | 7 - .../availability_reporting.test.tsx.snap | 2 + .../location_status_tags.test.tsx.snap | 96 +- .../__tests__/availability_reporting.test.tsx | 6 + .../__tests__/location_status_tags.test.tsx | 6 + .../__snapshots__/empty_state.test.tsx.snap | 16 +- .../__snapshots__/monitor_list.test.tsx.snap | 2 + .../__tests__/monitor_list.test.tsx | 6 + x-pack/test_utils/testbed/testbed.ts | 3 +- yarn.lock | 14 +- 93 files changed, 1406 insertions(+), 1224 deletions(-) diff --git a/package.json b/package.json index 1a497a2ec8b109..a45f240ce13afa 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,7 @@ "@elastic/datemath": "5.0.3", "@elastic/elasticsearch": "7.8.0", "@elastic/ems-client": "7.9.3", - "@elastic/eui": "24.1.0", + "@elastic/eui": "26.3.1", "@elastic/filesaver": "1.1.2", "@elastic/good": "8.1.1-kibana2", "@elastic/numeral": "^2.5.0", diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json index 6ea4a621f92f6e..8398d1c081da6d 100644 --- a/packages/kbn-ui-shared-deps/package.json +++ b/packages/kbn-ui-shared-deps/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@elastic/charts": "19.8.1", - "@elastic/eui": "24.1.0", + "@elastic/eui": "26.3.1", "@elastic/numeral": "^2.5.0", "@kbn/i18n": "1.0.0", "@kbn/monaco": "1.0.0", diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index 1b894bc400f082..d29120e6ee9ace 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { Breadcrumb as EuiBreadcrumb, IconType } from '@elastic/eui'; +import { EuiBreadcrumb, IconType } from '@elastic/eui'; import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { BehaviorSubject, combineLatest, merge, Observable, of, ReplaySubject } from 'rxjs'; diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index 1cfded4dc7b8f4..9ecbc055e33207 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -372,12 +372,13 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` handler={[Function]} /> } /> @@ -3908,16 +3909,9 @@ exports[`CollapsibleNav renders the default nav 2`] = ` handler={[Function]} /> - - } - /> - + />