diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index b8cffb36b821a..a91a5bec988b7 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -263,13 +263,14 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectAttribute](./kibana-plugin-core-server.savedobjectattribute.md) | Type definition for a Saved Object attribute value | | [SavedObjectAttributeSingle](./kibana-plugin-core-server.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-core-server.savedobjectattribute.md) | | [SavedObjectMigrationFn](./kibana-plugin-core-server.savedobjectmigrationfn.md) | A migration function for a [saved object type](./kibana-plugin-core-server.savedobjectstype.md) used to migrate it to a given version | -| [SavedObjectSanitizedDoc](./kibana-plugin-core-server.savedobjectsanitizeddoc.md) | | +| [SavedObjectSanitizedDoc](./kibana-plugin-core-server.savedobjectsanitizeddoc.md) | Describes Saved Object documents that have passed through the migration framework and are guaranteed to have a references root property. | | [SavedObjectsClientContract](./kibana-plugin-core-server.savedobjectsclientcontract.md) | Saved Objects is Kibana's data persisentence mechanism allowing plugins to use Elasticsearch for storing plugin state.\#\# SavedObjectsClient errorsSince the SavedObjectsClient has its hands in everything we are a little paranoid about the way we present errors back to to application code. Ideally, all errors will be either:1. Caused by bad implementation (ie. undefined is not a function) and as such unpredictable 2. An error that has been classified and decorated appropriately by the decorators in [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md)Type 1 errors are inevitable, but since all expected/handle-able errors should be Type 2 the isXYZError() helpers exposed at SavedObjectsErrorHelpers should be used to understand and manage error responses from the SavedObjectsClient.Type 2 errors are decorated versions of the source error, so if the elasticsearch client threw an error it will be decorated based on its type. That means that rather than looking for error.body.error.type or doing substring checks on error.body.error.reason, just use the helpers to understand the meaning of the error:\`\`\`js if (SavedObjectsErrorHelpers.isNotFoundError(error)) { // handle 404 }if (SavedObjectsErrorHelpers.isNotAuthorizedError(error)) { // 401 handling should be automatic, but in case you wanted to know }// always rethrow the error unless you handle it throw error; \`\`\`\#\#\# 404s from missing indexFrom the perspective of application code and APIs the SavedObjectsClient is a black box that persists objects. One of the internal details that users have no control over is that we use an elasticsearch index for persistance and that index might be missing.At the time of writing we are in the process of transitioning away from the operating assumption that the SavedObjects index is always available. Part of this transition is handling errors resulting from an index missing. These used to trigger a 500 error in most cases, and in others cause 404s with different error messages.From my (Spencer) perspective, a 404 from the SavedObjectsApi is a 404; The object the request/call was targeting could not be found. This is why \#14141 takes special care to ensure that 404 errors are generic and don't distinguish between index missing or document missing.See [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) See [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md) | | [SavedObjectsClientFactory](./kibana-plugin-core-server.savedobjectsclientfactory.md) | Describes the factory used to create instances of the Saved Objects Client. | | [SavedObjectsClientFactoryProvider](./kibana-plugin-core-server.savedobjectsclientfactoryprovider.md) | Provider to invoke to retrieve a [SavedObjectsClientFactory](./kibana-plugin-core-server.savedobjectsclientfactory.md). | | [SavedObjectsClientWrapperFactory](./kibana-plugin-core-server.savedobjectsclientwrapperfactory.md) | Describes the factory used to create instances of Saved Objects Client Wrappers. | | [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) | Describe a [saved object type mapping](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) field.Please refer to [elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html) For the mapping documentation | | [SavedObjectsNamespaceType](./kibana-plugin-core-server.savedobjectsnamespacetype.md) | The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global.Note: do not write logic that uses this value directly; instead, use the appropriate accessors in the [type registry](./kibana-plugin-core-server.savedobjecttyperegistry.md). | +| [SavedObjectUnsanitizedDoc](./kibana-plugin-core-server.savedobjectunsanitizeddoc.md) | Describes Saved Object documents from Kibana < 7.0.0 which don't have a references root property defined. This type should only be used in migrations. | | [ScopeableRequest](./kibana-plugin-core-server.scopeablerequest.md) | A user credentials container. It accommodates the necessary auth credentials to impersonate the current user.See [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md). | | [ServiceStatusLevel](./kibana-plugin-core-server.servicestatuslevel.md) | A convenience type that represents the union of each value in [ServiceStatusLevels](./kibana-plugin-core-server.servicestatuslevels.md). | | [SharedGlobalConfig](./kibana-plugin-core-server.sharedglobalconfig.md) | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsanitizeddoc.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsanitizeddoc.md index 47feb50e9a827..6d4e252fe7532 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsanitizeddoc.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsanitizeddoc.md @@ -4,6 +4,7 @@ ## SavedObjectSanitizedDoc type +Describes Saved Object documents that have passed through the migration framework and are guaranteed to have a `references` root property. Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectunsanitizeddoc.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectunsanitizeddoc.md new file mode 100644 index 0000000000000..be51400addbbc --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectunsanitizeddoc.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectUnsanitizedDoc](./kibana-plugin-core-server.savedobjectunsanitizeddoc.md) + +## SavedObjectUnsanitizedDoc type + +Describes Saved Object documents from Kibana < 7.0.0 which don't have a `references` root property defined. This type should only be used in migrations. + +Signature: + +```typescript +export declare type SavedObjectUnsanitizedDoc = SavedObjectDoc & Partial; +``` diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts index 64270c677ff20..3ec478e3ca28d 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts @@ -149,13 +149,13 @@ describe('DocumentMigrator', () => { expect(_.get(migratedDoc, 'attributes.name')).toBe('Mike'); }); - it('migrates meta properties', () => { + it('migrates root properties', () => { const migrator = new DocumentMigrator({ ...testOpts(), typeRegistry: createRegistry({ name: 'acl', migrations: { - '2.3.5': setAttr('acl', 'admins-only,sucka!'), + '2.3.5': setAttr('acl', 'admins-only, sucka!'), }, }), }); @@ -165,13 +165,13 @@ describe('DocumentMigrator', () => { attributes: { name: 'Tyler' }, acl: 'anyone', migrationVersion: {}, - }); + } as SavedObjectUnsanitizedDoc); expect(actual).toEqual({ id: 'me', type: 'user', attributes: { name: 'Tyler' }, migrationVersion: { acl: '2.3.5' }, - acl: 'admins-only,sucka!', + acl: 'admins-only, sucka!', }); }); @@ -241,7 +241,7 @@ describe('DocumentMigrator', () => { type: 'user', attributes: { name: 'Tyler' }, bbb: 'Shazm', - }); + } as SavedObjectUnsanitizedDoc); expect(actual).toEqual({ id: 'me', type: 'user', @@ -405,7 +405,7 @@ describe('DocumentMigrator', () => { attributes: { name: 'Callie' }, dawg: 'Yo', migrationVersion: {}, - }); + } as SavedObjectUnsanitizedDoc); expect(actual).toEqual({ id: 'smelly', type: 'foo', diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index 0284f513a361c..4ddb2b070d3ac 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -279,7 +279,7 @@ function props(doc: SavedObjectUnsanitizedDoc) { */ function propVersion(doc: SavedObjectUnsanitizedDoc | ActiveMigrations, prop: string) { return ( - (doc[prop] && doc[prop].latestVersion) || + ((doc as any)[prop] && (doc as any)[prop].latestVersion) || (doc.migrationVersion && (doc as any).migrationVersion[prop]) ); } diff --git a/src/core/server/saved_objects/serialization/types.ts b/src/core/server/saved_objects/serialization/types.ts index 7ea61f67e9496..a33e16895078e 100644 --- a/src/core/server/saved_objects/serialization/types.ts +++ b/src/core/server/saved_objects/serialization/types.ts @@ -45,10 +45,7 @@ export interface SavedObjectsRawDocSource { } /** - * A saved object type definition that allows for miscellaneous, unknown - * properties, as current discussions around security, ACLs, etc indicate - * that future props are likely to be added. Migrations support this - * scenario out of the box. + * Saved Object base document */ interface SavedObjectDoc { attributes: any; @@ -59,8 +56,6 @@ interface SavedObjectDoc { migrationVersion?: SavedObjectsMigrationVersion; version?: string; updated_at?: string; - - [rootProp: string]: any; } interface Referencable { @@ -68,14 +63,18 @@ interface Referencable { } /** - * We want to have two types, one that guarantees a "references" attribute - * will exist and one that allows it to be null. Since we're not migrating - * all the saved objects to have a "references" array, we need to support - * the scenarios where it may be missing (ex migrations). + * Describes Saved Object documents from Kibana < 7.0.0 which don't have a + * `references` root property defined. This type should only be used in + * migrations. * * @public */ export type SavedObjectUnsanitizedDoc = SavedObjectDoc & Partial; -/** @public */ +/** + * Describes Saved Object documents that have passed through the migration + * framework and are guaranteed to have a `references` root property. + * + * @public + */ export type SavedObjectSanitizedDoc = SavedObjectDoc & Referencable; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 2efa7dac393b4..a36e746f6d940 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1707,7 +1707,7 @@ export interface SavedObjectsAddToNamespacesOptions extends SavedObjectsBaseOpti // Warning: (ae-forgotten-export) The symbol "SavedObjectDoc" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "Referencable" needs to be exported by the entry point index.d.ts // -// @public (undocumented) +// @public export type SavedObjectSanitizedDoc = SavedObjectDoc & Referencable; // @public (undocumented) diff --git a/src/dev/run_check_published_api_changes.ts b/src/dev/run_check_published_api_changes.ts index ba3cd1280f34b..6d5fa04a93951 100644 --- a/src/dev/run_check_published_api_changes.ts +++ b/src/dev/run_check_published_api_changes.ts @@ -250,7 +250,7 @@ async function run( Options: --accept {dim Accepts all changes by updating the API Review files and documentation} --docs {dim Updates the Core API documentation} - --only {dim RegExp that folder names must match, folders: [${folders.join(', ')}]} + --filter {dim RegExp that folder names must match, folders: [${folders.join(', ')}]} --help {dim Show this message} `) ); diff --git a/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx b/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx index fb33649093c8d..f8632011002d0 100644 --- a/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx +++ b/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx @@ -275,6 +275,7 @@ class DashboardGridUi extends React.Component { getEmbeddableFactory={this.props.kibana.services.embeddable.getEmbeddableFactory} getAllEmbeddableFactories={this.props.kibana.services.embeddable.getEmbeddableFactories} overlays={this.props.kibana.services.overlays} + application={this.props.kibana.services.application} notifications={this.props.kibana.services.notifications} inspector={this.props.kibana.services.inspector} SavedObjectFinder={this.props.kibana.services.SavedObjectFinder} diff --git a/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx b/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx index 836cea298f035..5dab21ff671b4 100644 --- a/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx @@ -84,6 +84,7 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => { getAllEmbeddableFactories={(() => []) as any} getEmbeddableFactory={(() => null) as any} notifications={{} as any} + application={{} as any} overlays={{} as any} inspector={inspector} SavedObjectFinder={() => null} diff --git a/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx b/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx index d07bf915845e9..fc5438b8c8dcb 100644 --- a/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx +++ b/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx @@ -41,7 +41,7 @@ class EditableEmbeddable extends Embeddable { } test('is compatible when edit url is available, in edit mode and editable', async () => { - const action = new EditPanelAction(getFactory); + const action = new EditPanelAction(getFactory, {} as any); expect( await action.isCompatible({ embeddable: new EditableEmbeddable({ id: '123', viewMode: ViewMode.EDIT }, true), @@ -50,7 +50,7 @@ test('is compatible when edit url is available, in edit mode and editable', asyn }); test('getHref returns the edit urls', async () => { - const action = new EditPanelAction(getFactory); + const action = new EditPanelAction(getFactory, {} as any); expect(action.getHref).toBeDefined(); if (action.getHref) { @@ -64,7 +64,7 @@ test('getHref returns the edit urls', async () => { }); test('is not compatible when edit url is not available', async () => { - const action = new EditPanelAction(getFactory); + const action = new EditPanelAction(getFactory, {} as any); const embeddable = new ContactCardEmbeddable( { id: '123', @@ -83,7 +83,7 @@ test('is not compatible when edit url is not available', async () => { }); test('is not visible when edit url is available but in view mode', async () => { - const action = new EditPanelAction(getFactory); + const action = new EditPanelAction(getFactory, {} as any); expect( await action.isCompatible({ embeddable: new EditableEmbeddable( @@ -98,7 +98,7 @@ test('is not visible when edit url is available but in view mode', async () => { }); test('is not compatible when edit url is available, in edit mode, but not editable', async () => { - const action = new EditPanelAction(getFactory); + const action = new EditPanelAction(getFactory, {} as any); expect( await action.isCompatible({ embeddable: new EditableEmbeddable( diff --git a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts index 044e7b5d35ad8..0abbc25ff49a6 100644 --- a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts +++ b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts @@ -18,6 +18,7 @@ */ import { i18n } from '@kbn/i18n'; +import { ApplicationStart } from 'kibana/public'; import { Action } from 'src/plugins/ui_actions/public'; import { ViewMode } from '../types'; import { EmbeddableFactoryNotFoundError } from '../errors'; @@ -35,7 +36,10 @@ export class EditPanelAction implements Action { public readonly id = ACTION_EDIT_PANEL; public order = 15; - constructor(private readonly getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory']) {} + constructor( + private readonly getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory'], + private readonly application: ApplicationStart + ) {} public getDisplayName({ embeddable }: ActionContext) { const factory = this.getEmbeddableFactory(embeddable.type); @@ -56,18 +60,35 @@ export class EditPanelAction implements Action { public async isCompatible({ embeddable }: ActionContext) { const canEditEmbeddable = Boolean( - embeddable && embeddable.getOutput().editable && embeddable.getOutput().editUrl + embeddable && + embeddable.getOutput().editable && + (embeddable.getOutput().editUrl || + (embeddable.getOutput().editApp && embeddable.getOutput().editPath)) ); const inDashboardEditMode = embeddable.getInput().viewMode === ViewMode.EDIT; return Boolean(canEditEmbeddable && inDashboardEditMode); } public async execute(context: ActionContext) { + const appTarget = this.getAppTarget(context); + + if (appTarget) { + await this.application.navigateToApp(appTarget.app, { path: appTarget.path }); + return; + } + const href = await this.getHref(context); if (href) { - // TODO: when apps start using browser router instead of hash router this has to be fixed - // https://github.com/elastic/kibana/issues/58217 window.location.href = href; + return; + } + } + + public getAppTarget({ embeddable }: ActionContext): { app: string; path: string } | undefined { + const app = embeddable ? embeddable.getOutput().editApp : undefined; + const path = embeddable ? embeddable.getOutput().editPath : undefined; + if (app && path) { + return { app, path }; } } diff --git a/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.test.tsx b/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.test.tsx index 2a0ffd723850b..b046376a304ae 100644 --- a/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.test.tsx +++ b/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.test.tsx @@ -66,6 +66,7 @@ test('EmbeddableChildPanel renders an embeddable when it is done loading', async getAllEmbeddableFactories={start.getEmbeddableFactories} getEmbeddableFactory={getEmbeddableFactory} notifications={{} as any} + application={{} as any} overlays={{} as any} inspector={inspector} SavedObjectFinder={() => null} @@ -105,6 +106,7 @@ test(`EmbeddableChildPanel renders an error message if the factory doesn't exist getEmbeddableFactory={(() => undefined) as any} notifications={{} as any} overlays={{} as any} + application={{} as any} inspector={inspector} SavedObjectFinder={() => null} /> diff --git a/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.tsx b/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.tsx index 4c08a80a356bf..70628665e6e8c 100644 --- a/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.tsx +++ b/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.tsx @@ -40,6 +40,7 @@ export interface EmbeddableChildPanelProps { getAllEmbeddableFactories: EmbeddableStart['getEmbeddableFactories']; overlays: CoreStart['overlays']; notifications: CoreStart['notifications']; + application: CoreStart['application']; inspector: InspectorStartContract; SavedObjectFinder: React.ComponentType; } @@ -101,6 +102,7 @@ export class EmbeddableChildPanel extends React.Component { getAllEmbeddableFactories={start.getEmbeddableFactories} getEmbeddableFactory={start.getEmbeddableFactory} notifications={{} as any} + application={{} as any} overlays={{} as any} inspector={inspector} SavedObjectFinder={() => null} @@ -198,6 +199,7 @@ const renderInEditModeAndOpenContextMenu = async ( getEmbeddableFactory={start.getEmbeddableFactory} notifications={{} as any} overlays={{} as any} + application={{} as any} inspector={inspector} SavedObjectFinder={() => null} /> @@ -296,6 +298,7 @@ test('HelloWorldContainer in edit mode shows edit mode actions', async () => { getEmbeddableFactory={start.getEmbeddableFactory} notifications={{} as any} overlays={{} as any} + application={{} as any} inspector={inspector} SavedObjectFinder={() => null} /> @@ -358,6 +361,7 @@ test('Updates when hidePanelTitles is toggled', async () => { getEmbeddableFactory={start.getEmbeddableFactory} notifications={{} as any} overlays={{} as any} + application={{} as any} inspector={inspector} SavedObjectFinder={() => null} /> @@ -410,6 +414,7 @@ test('Check when hide header option is false', async () => { getEmbeddableFactory={start.getEmbeddableFactory} notifications={{} as any} overlays={{} as any} + application={{} as any} inspector={inspector} SavedObjectFinder={() => null} hideHeader={false} @@ -447,6 +452,7 @@ test('Check when hide header option is true', async () => { getEmbeddableFactory={start.getEmbeddableFactory} notifications={{} as any} overlays={{} as any} + application={{} as any} inspector={inspector} SavedObjectFinder={() => null} hideHeader={true} diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index b95060a73252f..c43359382a33d 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -45,6 +45,7 @@ interface Props { getAllEmbeddableFactories: EmbeddableStart['getEmbeddableFactories']; overlays: CoreStart['overlays']; notifications: CoreStart['notifications']; + application: CoreStart['application']; inspector: InspectorStartContract; SavedObjectFinder: React.ComponentType; hideHeader?: boolean; @@ -243,7 +244,7 @@ export class EmbeddablePanel extends React.Component { ), new InspectPanelAction(this.props.inspector), new RemovePanelAction(), - new EditPanelAction(this.props.getEmbeddableFactory), + new EditPanelAction(this.props.getEmbeddableFactory, this.props.application), ]; const sorted = actions diff --git a/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx b/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx index a88c3ba086325..31e14a0af59d7 100644 --- a/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx +++ b/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx @@ -49,6 +49,7 @@ interface HelloWorldContainerOptions { getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory']; getAllEmbeddableFactories: EmbeddableStart['getEmbeddableFactories']; overlays: CoreStart['overlays']; + application: CoreStart['application']; notifications: CoreStart['notifications']; inspector: InspectorStartContract; SavedObjectFinder: React.ComponentType; @@ -81,6 +82,7 @@ export class HelloWorldContainer extends Container; @@ -112,6 +113,7 @@ export class HelloWorldContainerComponent extends Component { getAllEmbeddableFactories={this.props.getAllEmbeddableFactories} overlays={this.props.overlays} notifications={this.props.notifications} + application={this.props.application} inspector={this.props.inspector} SavedObjectFinder={this.props.SavedObjectFinder} /> diff --git a/src/plugins/embeddable/public/plugin.tsx b/src/plugins/embeddable/public/plugin.tsx index 01fbf52c80182..36f49f2508e80 100644 --- a/src/plugins/embeddable/public/plugin.tsx +++ b/src/plugins/embeddable/public/plugin.tsx @@ -118,6 +118,7 @@ export class EmbeddablePublicPlugin implements Plugin diff --git a/src/plugins/embeddable/public/tests/apply_filter_action.test.ts b/src/plugins/embeddable/public/tests/apply_filter_action.test.ts index 3bd414ecf0d4a..ebb76c743393b 100644 --- a/src/plugins/embeddable/public/tests/apply_filter_action.test.ts +++ b/src/plugins/embeddable/public/tests/apply_filter_action.test.ts @@ -110,6 +110,7 @@ test('ApplyFilterAction is incompatible if the root container does not accept a getAllEmbeddableFactories: api.getEmbeddableFactories, overlays: coreStart.overlays, notifications: coreStart.notifications, + application: coreStart.application, inspector, SavedObjectFinder: () => null, } @@ -145,6 +146,7 @@ test('trying to execute on incompatible context throws an error ', async () => { getAllEmbeddableFactories: api.getEmbeddableFactories, overlays: coreStart.overlays, notifications: coreStart.notifications, + application: coreStart.application, inspector, SavedObjectFinder: () => null, } diff --git a/src/plugins/embeddable/public/tests/container.test.ts b/src/plugins/embeddable/public/tests/container.test.ts index 1aae43550ec6f..d2769e208ba42 100644 --- a/src/plugins/embeddable/public/tests/container.test.ts +++ b/src/plugins/embeddable/public/tests/container.test.ts @@ -74,6 +74,7 @@ async function creatHelloWorldContainerAndEmbeddable( getAllEmbeddableFactories: start.getEmbeddableFactories, overlays: coreStart.overlays, notifications: coreStart.notifications, + application: coreStart.application, inspector: {} as any, SavedObjectFinder: () => null, }); @@ -147,6 +148,7 @@ test('Container.removeEmbeddable removes and cleans up', async done => { getAllEmbeddableFactories: start.getEmbeddableFactories, overlays: coreStart.overlays, notifications: coreStart.notifications, + application: coreStart.application, inspector: {} as any, SavedObjectFinder: () => null, } @@ -327,6 +329,7 @@ test(`Container updates its state when a child's input is updated`, async done = getEmbeddableFactory: start.getEmbeddableFactory, notifications: coreStart.notifications, overlays: coreStart.overlays, + application: coreStart.application, inspector: {} as any, SavedObjectFinder: () => null, }); @@ -584,6 +587,7 @@ test('Container changes made directly after adding a new embeddable are propagat getAllEmbeddableFactories: start.getEmbeddableFactories, overlays: coreStart.overlays, notifications: coreStart.notifications, + application: coreStart.application, inspector: {} as any, SavedObjectFinder: () => null, } @@ -708,6 +712,7 @@ test('untilEmbeddableLoaded() throws an error if there is no such child panel in getAllEmbeddableFactories: start.getEmbeddableFactories, overlays: coreStart.overlays, notifications: coreStart.notifications, + application: coreStart.application, inspector: {} as any, SavedObjectFinder: () => null, } @@ -742,6 +747,7 @@ test('untilEmbeddableLoaded() resolves if child is loaded in the container', asy getAllEmbeddableFactories: start.getEmbeddableFactories, overlays: coreStart.overlays, notifications: coreStart.notifications, + application: coreStart.application, inspector: {} as any, SavedObjectFinder: () => null, } @@ -781,6 +787,7 @@ test('untilEmbeddableLoaded resolves with undefined if child is subsequently rem getAllEmbeddableFactories: start.getEmbeddableFactories, overlays: coreStart.overlays, notifications: coreStart.notifications, + application: coreStart.application, inspector: {} as any, SavedObjectFinder: () => null, } @@ -821,6 +828,7 @@ test('adding a panel then subsequently removing it before its loaded removes the getAllEmbeddableFactories: start.getEmbeddableFactories, overlays: coreStart.overlays, notifications: coreStart.notifications, + application: coreStart.application, inspector: {} as any, SavedObjectFinder: () => null, } diff --git a/src/plugins/embeddable/public/tests/customize_panel_modal.test.tsx b/src/plugins/embeddable/public/tests/customize_panel_modal.test.tsx index 19e461b8bde7e..a9cb83504d958 100644 --- a/src/plugins/embeddable/public/tests/customize_panel_modal.test.tsx +++ b/src/plugins/embeddable/public/tests/customize_panel_modal.test.tsx @@ -63,6 +63,7 @@ beforeEach(async () => { getAllEmbeddableFactories: api.getEmbeddableFactories, overlays: coreStart.overlays, notifications: coreStart.notifications, + application: coreStart.application, inspector: {} as any, SavedObjectFinder: () => null, } diff --git a/src/plugins/embeddable/public/tests/explicit_input.test.ts b/src/plugins/embeddable/public/tests/explicit_input.test.ts index 0e03db3ec8358..ef3c4b6f17e7f 100644 --- a/src/plugins/embeddable/public/tests/explicit_input.test.ts +++ b/src/plugins/embeddable/public/tests/explicit_input.test.ts @@ -88,6 +88,7 @@ test('Explicit embeddable input mapped to undefined with no inherited value will getEmbeddableFactory: start.getEmbeddableFactory, notifications: coreStart.notifications, overlays: coreStart.overlays, + application: coreStart.application, inspector: {} as any, SavedObjectFinder: () => null, } @@ -136,6 +137,7 @@ test('Explicit input tests in async situations', (done: () => void) => { getEmbeddableFactory: start.getEmbeddableFactory, notifications: coreStart.notifications, overlays: coreStart.overlays, + application: coreStart.application, inspector: {} as any, SavedObjectFinder: () => null, } diff --git a/src/plugins/vis_type_timeseries/server/routes/post_vis_schema.ts b/src/plugins/vis_type_timeseries/server/routes/post_vis_schema.ts index 3127e03ada0ef..fa4427fbb8c12 100644 --- a/src/plugins/vis_type_timeseries/server/routes/post_vis_schema.ts +++ b/src/plugins/vis_type_timeseries/server/routes/post_vis_schema.ts @@ -36,6 +36,7 @@ const queryObject = Joi.object({ language: Joi.string().allow(''), query: Joi.string().allow(''), }); +const stringOrNumberOptionalNullable = Joi.alternatives([stringOptionalNullable, numberOptional]); const numberOptionalOrEmptyString = Joi.alternatives(numberOptional, Joi.string().valid('')); const annotationsItems = Joi.object({ @@ -78,7 +79,7 @@ const metricsItems = Joi.object({ unit: stringOptionalNullable, model_type: stringOptionalNullable, mode: stringOptionalNullable, - lag: numberOptional, + lag: numberOptionalOrEmptyString, alpha: numberOptional, beta: numberOptional, gamma: numberOptional, @@ -130,8 +131,8 @@ const seriesItems = Joi.object({ aggregate_by: stringOptionalNullable, aggregate_function: stringOptionalNullable, axis_position: stringRequired, - axis_max: stringOptionalNullable, - axis_min: stringOptionalNullable, + axis_max: stringOrNumberOptionalNullable, + axis_min: stringOrNumberOptionalNullable, chart_type: stringRequired, color: stringRequired, color_rules: Joi.array() @@ -198,8 +199,8 @@ export const visPayloadSchema = Joi.object({ axis_formatter: stringRequired, axis_position: stringRequired, axis_scale: stringRequired, - axis_min: stringOptionalNullable, - axis_max: stringOptionalNullable, + axis_min: stringOrNumberOptionalNullable, + axis_max: stringOrNumberOptionalNullable, bar_color_rules: arrayNullable.optional(), background_color: stringOptionalNullable, background_color_rules: Joi.array() @@ -221,9 +222,9 @@ export const visPayloadSchema = Joi.object({ .optional(), gauge_width: [stringOptionalNullable, numberOptional], gauge_inner_color: stringOptionalNullable, - gauge_inner_width: Joi.alternatives(stringOptionalNullable, numberIntegerOptional), + gauge_inner_width: stringOrNumberOptionalNullable, gauge_style: stringOptionalNullable, - gauge_max: stringOptionalNullable, + gauge_max: stringOrNumberOptionalNullable, id: stringRequired, ignore_global_filters: numberOptional, ignore_global_filter: numberOptional, diff --git a/src/plugins/vis_type_timeseries/server/saved_objects/tsvb_telemetry.ts b/src/plugins/vis_type_timeseries/server/saved_objects/tsvb_telemetry.ts index f18fa1e4cc2fa..34922976f22ff 100644 --- a/src/plugins/vis_type_timeseries/server/saved_objects/tsvb_telemetry.ts +++ b/src/plugins/vis_type_timeseries/server/saved_objects/tsvb_telemetry.ts @@ -41,5 +41,6 @@ export const tsvbTelemetrySavedObjectType: SavedObjectsType = { }, migrations: { '7.7.0': flow(resetCount), + '7.8.0': flow(resetCount), }, }; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx index ee08dfb87e1c1..1e8983a0ca5e5 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx @@ -45,6 +45,7 @@ const renderEmbeddableFactory = (core: CoreStart, plugins: StartDeps) => { getAllEmbeddableFactories={plugins.embeddable.getEmbeddableFactories} notifications={core.notifications} overlays={core.overlays} + application={core.application} inspector={plugins.inspector} SavedObjectFinder={getSavedObjectFinder(core.savedObjects, core.uiSettings)} /> diff --git a/x-pack/legacy/plugins/reporting/server/usage/__snapshots__/reporting_usage_collector.test.ts.snap b/x-pack/legacy/plugins/reporting/server/usage/__snapshots__/reporting_usage_collector.test.ts.snap new file mode 100644 index 0000000000000..aa22c3f66df18 --- /dev/null +++ b/x-pack/legacy/plugins/reporting/server/usage/__snapshots__/reporting_usage_collector.test.ts.snap @@ -0,0 +1,343 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`data modeling with empty data 1`] = ` +Object { + "PNG": Object { + "available": true, + "total": 0, + }, + "_all": 0, + "available": true, + "browser_type": undefined, + "csv": Object { + "available": true, + "total": 0, + }, + "enabled": true, + "last7Days": Object { + "PNG": Object { + "available": true, + "total": 0, + }, + "_all": 0, + "csv": Object { + "available": true, + "total": 0, + }, + "printable_pdf": Object { + "app": Object { + "dashboard": 0, + "visualization": 0, + }, + "available": true, + "layout": Object { + "preserve_layout": 0, + "print": 0, + }, + "total": 0, + }, + "status": Object { + "completed": 0, + "failed": 0, + }, + "statuses": Object {}, + }, + "lastDay": Object { + "PNG": Object { + "available": true, + "total": 0, + }, + "_all": 0, + "csv": Object { + "available": true, + "total": 0, + }, + "printable_pdf": Object { + "app": Object { + "dashboard": 0, + "visualization": 0, + }, + "available": true, + "layout": Object { + "preserve_layout": 0, + "print": 0, + }, + "total": 0, + }, + "status": Object { + "completed": 0, + "failed": 0, + }, + "statuses": Object {}, + }, + "printable_pdf": Object { + "app": Object { + "dashboard": 0, + "visualization": 0, + }, + "available": true, + "layout": Object { + "preserve_layout": 0, + "print": 0, + }, + "total": 0, + }, + "status": Object { + "completed": 0, + "failed": 0, + }, + "statuses": Object {}, +} +`; + +exports[`data modeling with normal looking usage data 1`] = ` +Object { + "PNG": Object { + "available": true, + "total": 3, + }, + "_all": 12, + "available": true, + "browser_type": undefined, + "csv": Object { + "available": true, + "total": 0, + }, + "enabled": true, + "last7Days": Object { + "PNG": Object { + "available": true, + "total": 1, + }, + "_all": 1, + "csv": Object { + "available": true, + "total": 0, + }, + "printable_pdf": Object { + "app": Object { + "dashboard": 0, + "visualization": 0, + }, + "available": true, + "layout": Object { + "preserve_layout": 0, + "print": 0, + }, + "total": 0, + }, + "status": Object { + "completed": 0, + "completed_with_warnings": 1, + "failed": 0, + }, + "statuses": Object { + "completed_with_warnings": Object { + "PNG": Object { + "dashboard": 1, + }, + }, + }, + }, + "lastDay": Object { + "PNG": Object { + "available": true, + "total": 1, + }, + "_all": 1, + "csv": Object { + "available": true, + "total": 0, + }, + "printable_pdf": Object { + "app": Object { + "dashboard": 0, + "visualization": 0, + }, + "available": true, + "layout": Object { + "preserve_layout": 0, + "print": 0, + }, + "total": 0, + }, + "status": Object { + "completed": 0, + "completed_with_warnings": 1, + "failed": 0, + }, + "statuses": Object { + "completed_with_warnings": Object { + "PNG": Object { + "dashboard": 1, + }, + }, + }, + }, + "printable_pdf": Object { + "app": Object { + "canvas workpad": 6, + "dashboard": 0, + "visualization": 3, + }, + "available": true, + "layout": Object { + "preserve_layout": 9, + "print": 0, + }, + "total": 9, + }, + "status": Object { + "completed": 10, + "completed_with_warnings": 1, + "failed": 1, + }, + "statuses": Object { + "completed": Object { + "PNG": Object { + "visualization": 1, + }, + "printable_pdf": Object { + "canvas workpad": 6, + "visualization": 3, + }, + }, + "completed_with_warnings": Object { + "PNG": Object { + "dashboard": 1, + }, + }, + "failed": Object { + "PNG": Object { + "dashboard": 1, + }, + }, + }, +} +`; + +exports[`data modeling with sparse data 1`] = ` +Object { + "PNG": Object { + "available": true, + "total": 1, + }, + "_all": 4, + "available": true, + "browser_type": undefined, + "csv": Object { + "available": true, + "total": 1, + }, + "enabled": true, + "last7Days": Object { + "PNG": Object { + "available": true, + "total": 1, + }, + "_all": 4, + "csv": Object { + "available": true, + "total": 1, + }, + "printable_pdf": Object { + "app": Object { + "canvas workpad": 1, + "dashboard": 1, + "visualization": 0, + }, + "available": true, + "layout": Object { + "preserve_layout": 2, + "print": 0, + }, + "total": 2, + }, + "status": Object { + "completed": 4, + "failed": 0, + }, + "statuses": Object { + "completed": Object { + "PNG": Object { + "dashboard": 1, + }, + "csv": Object {}, + "printable_pdf": Object { + "canvas workpad": 1, + "dashboard": 1, + }, + }, + }, + }, + "lastDay": Object { + "PNG": Object { + "available": true, + "total": 1, + }, + "_all": 4, + "csv": Object { + "available": true, + "total": 1, + }, + "printable_pdf": Object { + "app": Object { + "canvas workpad": 1, + "dashboard": 1, + "visualization": 0, + }, + "available": true, + "layout": Object { + "preserve_layout": 2, + "print": 0, + }, + "total": 2, + }, + "status": Object { + "completed": 4, + "failed": 0, + }, + "statuses": Object { + "completed": Object { + "PNG": Object { + "dashboard": 1, + }, + "csv": Object {}, + "printable_pdf": Object { + "canvas workpad": 1, + "dashboard": 1, + }, + }, + }, + }, + "printable_pdf": Object { + "app": Object { + "canvas workpad": 1, + "dashboard": 1, + "visualization": 0, + }, + "available": true, + "layout": Object { + "preserve_layout": 2, + "print": 0, + }, + "total": 2, + }, + "status": Object { + "completed": 4, + "failed": 0, + }, + "statuses": Object { + "completed": Object { + "PNG": Object { + "dashboard": 1, + }, + "csv": Object {}, + "printable_pdf": Object { + "canvas workpad": 1, + "dashboard": 1, + }, + }, + }, +} +`; diff --git a/x-pack/legacy/plugins/reporting/server/usage/decorate_range_stats.ts b/x-pack/legacy/plugins/reporting/server/usage/decorate_range_stats.ts index 359bcc45230c3..ef985d2dd1cf3 100644 --- a/x-pack/legacy/plugins/reporting/server/usage/decorate_range_stats.ts +++ b/x-pack/legacy/plugins/reporting/server/usage/decorate_range_stats.ts @@ -6,7 +6,7 @@ import { uniq } from 'lodash'; import { CSV_JOB_TYPE, PDF_JOB_TYPE, PNG_JOB_TYPE } from '../../common/constants'; -import { AvailableTotal, FeatureAvailabilityMap, RangeStats, ExportType } from './types'; +import { AvailableTotal, ExportType, FeatureAvailabilityMap, RangeStats } from './types'; function getForFeature( range: Partial, @@ -47,6 +47,7 @@ export const decorateRangeStats = ( const { _all: rangeAll, status: rangeStatus, + statuses: rangeStatusByApp, [PDF_JOB_TYPE]: rangeStatsPdf, ...rangeStatsBasic } = rangeStats; @@ -73,6 +74,7 @@ export const decorateRangeStats = ( const resultStats = { _all: rangeAll || 0, status: { completed: 0, failed: 0, ...rangeStatus }, + statuses: rangeStatusByApp, ...rangePdf, ...rangeBasic, } as RangeStats; diff --git a/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts b/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts index e9523d9e70202..2c3bb8f4bf71c 100644 --- a/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts +++ b/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts @@ -11,13 +11,13 @@ import { ReportingConfig } from '../types'; import { decorateRangeStats } from './decorate_range_stats'; import { getExportTypesHandler } from './get_export_type_handler'; import { - AggregationBuckets, - AggregationResults, + AggregationResultBuckets, FeatureAvailabilityMap, JobTypes, KeyCountBucket, - RangeAggregationResults, RangeStats, + SearchResponse, + StatusByAppBucket, } from './types'; type XPackInfo = XPackMainPlugin['info']; @@ -29,6 +29,7 @@ const LAYOUT_TYPES_FIELD = 'meta.layout.keyword'; const OBJECT_TYPES_KEY = 'objectTypes'; const OBJECT_TYPES_FIELD = 'meta.objectType.keyword'; const STATUS_TYPES_KEY = 'statusTypes'; +const STATUS_BY_APP_KEY = 'statusByApp'; const STATUS_TYPES_FIELD = 'status'; const DEFAULT_TERMS_SIZE = 10; @@ -38,16 +39,30 @@ const PRINTABLE_PDF_JOBTYPE = 'printable_pdf'; const getKeyCount = (buckets: KeyCountBucket[]): { [key: string]: number } => buckets.reduce((accum, { key, doc_count: count }) => ({ ...accum, [key]: count }), {}); -function getAggStats(aggs: AggregationResults) { - const { buckets: jobBuckets } = aggs[JOB_TYPES_KEY] as AggregationBuckets; - const jobTypes: JobTypes = jobBuckets.reduce( +// indexes some key/count buckets by statusType > jobType > appName: statusCount +const getAppStatuses = (buckets: StatusByAppBucket[]) => + buckets.reduce((statuses, statusBucket) => { + return { + ...statuses, + [statusBucket.key]: statusBucket.jobTypes.buckets.reduce((jobTypes, job) => { + return { + ...jobTypes, + [job.key]: job.appNames.buckets.reduce((apps, app) => { + return { + ...apps, + [app.key]: app.doc_count, + }; + }, {}), + }; + }, {}), + }; + }, {}); + +function getAggStats(aggs: AggregationResultBuckets): RangeStats { + const { buckets: jobBuckets } = aggs[JOB_TYPES_KEY]; + const jobTypes = jobBuckets.reduce( (accum: JobTypes, { key, doc_count: count }: { key: string; doc_count: number }) => { - return { - ...accum, - [key]: { - total: count, - }, - }; + return { ...accum, [key]: { total: count } }; }, {} as JobTypes ); @@ -55,8 +70,8 @@ function getAggStats(aggs: AggregationResults) { // merge pdf stats into pdf jobtype key const pdfJobs = jobTypes[PRINTABLE_PDF_JOBTYPE]; if (pdfJobs) { - const pdfAppBuckets = get(aggs[OBJECT_TYPES_KEY], '.pdf.buckets', []); - const pdfLayoutBuckets = get(aggs[LAYOUT_TYPES_KEY], '.pdf.buckets', []); + const pdfAppBuckets = get(aggs[OBJECT_TYPES_KEY], '.pdf.buckets', []); + const pdfLayoutBuckets = get(aggs[LAYOUT_TYPES_KEY], '.pdf.buckets', []); pdfJobs.app = getKeyCount(pdfAppBuckets) as { visualization: number; dashboard: number; @@ -69,26 +84,35 @@ function getAggStats(aggs: AggregationResults) { const all = aggs.doc_count as number; let statusTypes = {}; - const statusBuckets = get(aggs[STATUS_TYPES_KEY], 'buckets', []); + const statusBuckets = get(aggs[STATUS_TYPES_KEY], 'buckets', []); if (statusBuckets) { statusTypes = getKeyCount(statusBuckets); } - return { _all: all, status: statusTypes, ...jobTypes }; + let statusByApp = {}; + const statusAppBuckets = get(aggs[STATUS_BY_APP_KEY], 'buckets', []); + if (statusAppBuckets) { + statusByApp = getAppStatuses(statusAppBuckets); + } + + return { _all: all, status: statusTypes, statuses: statusByApp, ...jobTypes }; } +type SearchAggregation = SearchResponse['aggregations']['ranges']['buckets']; + type RangeStatSets = Partial< RangeStats & { lastDay: RangeStats; last7Days: RangeStats; } >; -async function handleResponse(response: AggregationResults): Promise { - const buckets = get(response, 'aggregations.ranges.buckets'); + +async function handleResponse(response: SearchResponse): Promise { + const buckets = get(response, 'aggregations.ranges.buckets'); if (!buckets) { return {}; } - const { lastDay, last7Days, all } = buckets as RangeAggregationResults; + const { lastDay, last7Days, all } = buckets; const lastDayUsage = lastDay ? getAggStats(lastDay) : ({} as RangeStats); const last7DaysUsage = last7Days ? getAggStats(last7Days) : ({} as RangeStats); @@ -126,6 +150,17 @@ export async function getReportingUsage( aggs: { [JOB_TYPES_KEY]: { terms: { field: JOB_TYPES_FIELD, size: DEFAULT_TERMS_SIZE } }, [STATUS_TYPES_KEY]: { terms: { field: STATUS_TYPES_FIELD, size: DEFAULT_TERMS_SIZE } }, + [STATUS_BY_APP_KEY]: { + terms: { field: 'status', size: DEFAULT_TERMS_SIZE }, + aggs: { + jobTypes: { + terms: { field: JOB_TYPES_FIELD, size: DEFAULT_TERMS_SIZE }, + aggs: { + appNames: { terms: { field: OBJECT_TYPES_FIELD, size: DEFAULT_TERMS_SIZE } }, // NOTE Discover/CSV export is missing the 'meta.objectType' field, so Discover/CSV results are missing for this agg + }, + }, + }, + }, [OBJECT_TYPES_KEY]: { filter: { term: { jobtype: PRINTABLE_PDF_JOBTYPE } }, aggs: { pdf: { terms: { field: OBJECT_TYPES_FIELD, size: DEFAULT_TERMS_SIZE } } }, @@ -141,7 +176,7 @@ export async function getReportingUsage( }; return callCluster('search', params) - .then((response: AggregationResults) => handleResponse(response)) + .then((response: SearchResponse) => handleResponse(response)) .then((usage: RangeStatSets) => { // Allow this to explicitly throw an exception if/when this config is deprecated, // because we shouldn't collect browserType in that case! diff --git a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.ts b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.ts index dbc674ce36ec8..61b736a3e4d8c 100644 --- a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.ts +++ b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.ts @@ -12,6 +12,7 @@ import { getReportingUsageCollector, } from './reporting_usage_collector'; import { ReportingConfig } from '../types'; +import { SearchResponse } from './types'; const exportTypesRegistry = getExportTypesRegistry(); @@ -61,7 +62,7 @@ const getMockReportingConfig = () => ({ get: () => {}, kbnConfig: { get: () => '' }, }); -const getResponseMock = (customization = {}) => customization; +const getResponseMock = (base = {}) => base; describe('license checks', () => { let mockConfig: ReportingConfig; @@ -206,212 +207,143 @@ describe('data modeling', () => { ranges: { buckets: { all: { - doc_count: 54, - layoutTypes: { - doc_count: 23, - pdf: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: 'preserve_layout', doc_count: 13 }, - { key: 'print', doc_count: 10 }, - ], - }, - }, - objectTypes: { - doc_count: 23, - pdf: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [{ key: 'dashboard', doc_count: 23 }], - }, - }, - statusTypes: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: 'pending', doc_count: 33 }, - { key: 'completed', doc_count: 20 }, - { key: 'processing', doc_count: 1 }, - ], - }, - jobTypes: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: 'csv', doc_count: 27 }, - { key: 'printable_pdf', doc_count: 23 }, - { key: 'PNG', doc_count: 4 }, - ], - }, - }, - lastDay: { - doc_count: 11, - layoutTypes: { - doc_count: 2, - pdf: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [{ key: 'print', doc_count: 2 }], - }, - }, - objectTypes: { - doc_count: 2, - pdf: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [{ key: 'dashboard', doc_count: 2 }], - }, - }, - statusTypes: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [{ key: 'pending', doc_count: 11 }], - }, - jobTypes: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: 'csv', doc_count: 5 }, - { key: 'PNG', doc_count: 4 }, - { key: 'printable_pdf', doc_count: 2 }, - ], - }, + doc_count: 12, + jobTypes: { buckets: [ { doc_count: 9, key: 'printable_pdf' }, { doc_count: 3, key: 'PNG' }, ], }, + layoutTypes: { doc_count: 9, pdf: { buckets: [{ doc_count: 9, key: 'preserve_layout' }] }, }, + objectTypes: { doc_count: 9, pdf: { buckets: [ { doc_count: 6, key: 'canvas workpad' }, { doc_count: 3, key: 'visualization' }, ], }, }, + statusByApp: { buckets: [ { doc_count: 10, jobTypes: { buckets: [ { appNames: { buckets: [ { doc_count: 6, key: 'canvas workpad' }, { doc_count: 3, key: 'visualization' }, ], }, doc_count: 9, key: 'printable_pdf', }, { appNames: { buckets: [{ doc_count: 1, key: 'visualization' }] }, doc_count: 1, key: 'PNG', }, ], }, key: 'completed', }, { doc_count: 1, jobTypes: { buckets: [ { appNames: { buckets: [{ doc_count: 1, key: 'dashboard' }] }, doc_count: 1, key: 'PNG', }, ], }, key: 'completed_with_warnings', }, { doc_count: 1, jobTypes: { buckets: [ { appNames: { buckets: [{ doc_count: 1, key: 'dashboard' }] }, doc_count: 1, key: 'PNG', }, ], }, key: 'failed', }, ], }, + statusTypes: { buckets: [ { doc_count: 10, key: 'completed' }, { doc_count: 1, key: 'completed_with_warnings' }, { doc_count: 1, key: 'failed' }, ], }, }, last7Days: { - doc_count: 27, - layoutTypes: { - doc_count: 13, - pdf: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: 'print', doc_count: 10 }, - { key: 'preserve_layout', doc_count: 3 }, - ], - }, - }, - objectTypes: { - doc_count: 13, - pdf: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [{ key: 'dashboard', doc_count: 13 }], - }, - }, - statusTypes: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [{ key: 'pending', doc_count: 27 }], - }, - jobTypes: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: 'printable_pdf', doc_count: 13 }, - { key: 'csv', doc_count: 10 }, - { key: 'PNG', doc_count: 4 }, - ], - }, + doc_count: 1, + jobTypes: { buckets: [{ doc_count: 1, key: 'PNG' }] }, + layoutTypes: { doc_count: 0, pdf: { buckets: [] } }, + objectTypes: { doc_count: 0, pdf: { buckets: [] } }, + statusByApp: { buckets: [ { doc_count: 1, jobTypes: { buckets: [ { appNames: { buckets: [{ doc_count: 1, key: 'dashboard' }] }, doc_count: 1, key: 'PNG', }, ], }, key: 'completed_with_warnings', }, ], }, + statusTypes: { buckets: [{ doc_count: 1, key: 'completed_with_warnings' }] }, + }, + lastDay: { + doc_count: 1, + jobTypes: { buckets: [{ doc_count: 1, key: 'PNG' }] }, + layoutTypes: { doc_count: 0, pdf: { buckets: [] } }, + objectTypes: { doc_count: 0, pdf: { buckets: [] } }, + statusByApp: { buckets: [ { doc_count: 1, jobTypes: { buckets: [ { appNames: { buckets: [{ doc_count: 1, key: 'dashboard' }] }, doc_count: 1, key: 'PNG', }, ], }, key: 'completed_with_warnings', }, ], }, + statusTypes: { buckets: [{ doc_count: 1, key: 'completed_with_warnings' }] }, }, }, }, }, - }) + } as SearchResponse) // prettier-ignore ) ); const usageStats = await fetch(callClusterMock as any); - expect(usageStats).toMatchInlineSnapshot(` - Object { - "PNG": Object { - "available": true, - "total": 4, - }, - "_all": 54, - "available": true, - "browser_type": undefined, - "csv": Object { - "available": true, - "total": 27, - }, - "enabled": true, - "last7Days": Object { - "PNG": Object { - "available": true, - "total": 4, - }, - "_all": 27, - "csv": Object { - "available": true, - "total": 10, - }, - "printable_pdf": Object { - "app": Object { - "dashboard": 13, - "visualization": 0, - }, - "available": true, - "layout": Object { - "preserve_layout": 3, - "print": 10, + expect(usageStats).toMatchSnapshot(); + }); + + test('with sparse data', async () => { + const mockConfig = getMockReportingConfig(); + const plugins = getPluginsMock(); + const { fetch } = getReportingUsageCollector( + mockConfig, + plugins.usageCollection, + plugins.__LEGACY.plugins.xpack_main.info, + exportTypesRegistry, + function isReady() { + return Promise.resolve(true); + } + ); + const callClusterMock = jest.fn(() => + Promise.resolve( + getResponseMock({ + aggregations: { + ranges: { + buckets: { + all: { + doc_count: 4, + layoutTypes: { doc_count: 2, pdf: { buckets: [{ key: 'preserve_layout', doc_count: 2 }] }, }, + statusByApp: { buckets: [ { key: 'completed', doc_count: 4, jobTypes: { buckets: [ { key: 'printable_pdf', doc_count: 2, appNames: { buckets: [ { key: 'canvas workpad', doc_count: 1 }, { key: 'dashboard', doc_count: 1 }, ], }, }, { key: 'PNG', doc_count: 1, appNames: { buckets: [{ key: 'dashboard', doc_count: 1 }] }, }, { key: 'csv', doc_count: 1, appNames: { buckets: [] } }, ], }, }, ], }, + objectTypes: { doc_count: 2, pdf: { buckets: [ { key: 'canvas workpad', doc_count: 1 }, { key: 'dashboard', doc_count: 1 }, ], }, }, + statusTypes: { buckets: [{ key: 'completed', doc_count: 4 }] }, + jobTypes: { buckets: [ { key: 'printable_pdf', doc_count: 2 }, { key: 'PNG', doc_count: 1 }, { key: 'csv', doc_count: 1 }, ], }, + }, + last7Days: { + doc_count: 4, + layoutTypes: { doc_count: 2, pdf: { buckets: [{ key: 'preserve_layout', doc_count: 2 }] }, }, + statusByApp: { buckets: [ { key: 'completed', doc_count: 4, jobTypes: { buckets: [ { key: 'printable_pdf', doc_count: 2, appNames: { buckets: [ { key: 'canvas workpad', doc_count: 1 }, { key: 'dashboard', doc_count: 1 }, ], }, }, { key: 'PNG', doc_count: 1, appNames: { buckets: [{ key: 'dashboard', doc_count: 1 }] }, }, { key: 'csv', doc_count: 1, appNames: { buckets: [] } }, ], }, }, ], }, + objectTypes: { doc_count: 2, pdf: { buckets: [ { key: 'canvas workpad', doc_count: 1 }, { key: 'dashboard', doc_count: 1 }, ], }, }, + statusTypes: { buckets: [{ key: 'completed', doc_count: 4 }] }, + jobTypes: { buckets: [ { key: 'printable_pdf', doc_count: 2 }, { key: 'PNG', doc_count: 1 }, { key: 'csv', doc_count: 1 }, ], }, + }, + lastDay: { + doc_count: 4, + layoutTypes: { doc_count: 2, pdf: { buckets: [{ key: 'preserve_layout', doc_count: 2 }] }, }, + statusByApp: { buckets: [ { key: 'completed', doc_count: 4, jobTypes: { buckets: [ { key: 'printable_pdf', doc_count: 2, appNames: { buckets: [ { key: 'canvas workpad', doc_count: 1 }, { key: 'dashboard', doc_count: 1 }, ], }, }, { key: 'PNG', doc_count: 1, appNames: { buckets: [{ key: 'dashboard', doc_count: 1 }] }, }, { key: 'csv', doc_count: 1, appNames: { buckets: [] } }, ], }, }, ], }, + objectTypes: { doc_count: 2, pdf: { buckets: [ { key: 'canvas workpad', doc_count: 1 }, { key: 'dashboard', doc_count: 1 }, ], }, }, + statusTypes: { buckets: [{ key: 'completed', doc_count: 4 }] }, + jobTypes: { buckets: [ { key: 'printable_pdf', doc_count: 2 }, { key: 'PNG', doc_count: 1 }, { key: 'csv', doc_count: 1 }, ], }, + }, + }, }, - "total": 13, }, - "status": Object { - "completed": 0, - "failed": 0, - "pending": 27, - }, - }, - "lastDay": Object { - "PNG": Object { - "available": true, - "total": 4, - }, - "_all": 11, - "csv": Object { - "available": true, - "total": 5, - }, - "printable_pdf": Object { - "app": Object { - "dashboard": 2, - "visualization": 0, - }, - "available": true, - "layout": Object { - "preserve_layout": 0, - "print": 2, + } as SearchResponse) // prettier-ignore + ) + ); + + const usageStats = await fetch(callClusterMock as any); + expect(usageStats).toMatchSnapshot(); + }); + + test('with empty data', async () => { + const mockConfig = getMockReportingConfig(); + const plugins = getPluginsMock(); + const { fetch } = getReportingUsageCollector( + mockConfig, + plugins.usageCollection, + plugins.__LEGACY.plugins.xpack_main.info, + exportTypesRegistry, + function isReady() { + return Promise.resolve(true); + } + ); + const callClusterMock = jest.fn(() => + Promise.resolve( + getResponseMock({ + aggregations: { + ranges: { + buckets: { + all: { + doc_count: 0, + jobTypes: { buckets: [] }, + layoutTypes: { doc_count: 0, pdf: { buckets: [] } }, + objectTypes: { doc_count: 0, pdf: { buckets: [] } }, + statusByApp: { buckets: [] }, + statusTypes: { buckets: [] }, + }, + last7Days: { + doc_count: 0, + jobTypes: { buckets: [] }, + layoutTypes: { doc_count: 0, pdf: { buckets: [] } }, + objectTypes: { doc_count: 0, pdf: { buckets: [] } }, + statusByApp: { buckets: [] }, + statusTypes: { buckets: [] }, + }, + lastDay: { + doc_count: 0, + jobTypes: { buckets: [] }, + layoutTypes: { doc_count: 0, pdf: { buckets: [] } }, + objectTypes: { doc_count: 0, pdf: { buckets: [] } }, + statusByApp: { buckets: [] }, + statusTypes: { buckets: [] }, + }, + }, }, - "total": 2, - }, - "status": Object { - "completed": 0, - "failed": 0, - "pending": 11, - }, - }, - "printable_pdf": Object { - "app": Object { - "dashboard": 23, - "visualization": 0, }, - "available": true, - "layout": Object { - "preserve_layout": 13, - "print": 10, - }, - "total": 23, - }, - "status": Object { - "completed": 20, - "failed": 0, - "pending": 33, - "processing": 1, - }, - } - `); + } as SearchResponse) + ) + ); + const usageStats = await fetch(callClusterMock as any); + expect(usageStats).toMatchSnapshot(); }); }); diff --git a/x-pack/legacy/plugins/reporting/server/usage/types.d.ts b/x-pack/legacy/plugins/reporting/server/usage/types.d.ts index 98e025ccf661e..83f1701863355 100644 --- a/x-pack/legacy/plugins/reporting/server/usage/types.d.ts +++ b/x-pack/legacy/plugins/reporting/server/usage/types.d.ts @@ -4,15 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export interface AvailableTotal { - available: boolean; - total: number; -} - -interface StatusCounts { - [statusType: string]: number; -} - export interface KeyCountBucket { key: string; doc_count: number; @@ -20,22 +11,53 @@ export interface KeyCountBucket { export interface AggregationBuckets { buckets: KeyCountBucket[]; - pdf?: { - buckets: KeyCountBucket[]; - }; } -/* - * Mapped Types and Intersection Types - */ +export interface StatusByAppBucket { + key: string; + doc_count: number; + jobTypes: { + buckets: Array<{ + doc_count: number; + key: string; + appNames: AggregationBuckets; + }>; + }; +} -type AggregationKeys = 'jobTypes' | 'layoutTypes' | 'objectTypes' | 'statusTypes'; -export type AggregationResults = { [K in AggregationKeys]: AggregationBuckets } & { +export interface AggregationResultBuckets { + jobTypes: AggregationBuckets; + layoutTypes: { + doc_count: number; + pdf: AggregationBuckets; + }; + objectTypes: { + doc_count: number; + pdf: AggregationBuckets; + }; + statusTypes: AggregationBuckets; + statusByApp: { + buckets: StatusByAppBucket[]; + }; doc_count: number; -}; +} -type RangeAggregationKeys = 'all' | 'lastDay' | 'last7Days'; -export type RangeAggregationResults = { [K in RangeAggregationKeys]?: AggregationResults }; +export interface SearchResponse { + aggregations: { + ranges: { + buckets: { + all: AggregationResultBuckets; + last7Days: AggregationResultBuckets; + lastDay: AggregationResultBuckets; + }; + }; + }; +} + +export interface AvailableTotal { + available: boolean; + total: number; +} type BaseJobTypeKeys = 'csv' | 'PNG'; export type JobTypes = { [K in BaseJobTypeKeys]: AvailableTotal } & { @@ -51,9 +73,22 @@ export type JobTypes = { [K in BaseJobTypeKeys]: AvailableTotal } & { }; }; +interface StatusCounts { + [statusType: string]: number; +} + +interface StatusByAppCounts { + [statusType: string]: { + [jobType: string]: { + [appName: string]: number; + }; + }; +} + export type RangeStats = JobTypes & { _all: number; status: StatusCounts; + statuses: StatusByAppCounts; }; export type ExportType = 'csv' | 'printable_pdf' | 'PNG'; diff --git a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx index a7272593c2b27..e18f9b0d346ad 100644 --- a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx +++ b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx @@ -208,6 +208,7 @@ export const EmbeddedMapComponent = ({ notifications={services.notifications} overlays={services.overlays} inspector={services.inspector} + application={services.application} SavedObjectFinder={getSavedObjectFinder(services.savedObjects, services.uiSettings)} /> ) : !isLoading && isIndexError ? ( diff --git a/x-pack/legacy/plugins/siem/public/containers/case/configure/mock.ts b/x-pack/legacy/plugins/siem/public/containers/case/configure/mock.ts index d2491b39fdf56..388875724afe9 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/configure/mock.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/configure/mock.ts @@ -9,8 +9,25 @@ import { CasesConfigureResponse, CasesConfigureRequest, } from '../../../../../../../plugins/case/common/api'; -import { CaseConfigure } from './types'; +import { CaseConfigure, CasesConfigurationMapping } from './types'; +export const mapping: CasesConfigurationMapping[] = [ + { + source: 'title', + target: 'short_description', + actionType: 'overwrite', + }, + { + source: 'description', + target: 'description', + actionType: 'append', + }, + { + source: 'comments', + target: 'comments', + actionType: 'append', + }, +]; export const connectorsMock: Connector[] = [ { id: '123', @@ -19,26 +36,10 @@ export const connectorsMock: Connector[] = [ config: { apiUrl: 'https://instance1.service-now.com', casesConfiguration: { - mapping: [ - { - source: 'title', - target: 'short_description', - actionType: 'overwrite', - }, - { - source: 'description', - target: 'description', - actionType: 'append', - }, - { - source: 'comments', - target: 'comments', - actionType: 'append', - }, - ], + mapping, }, }, - isPreconfigured: true, + isPreconfigured: false, }, { id: '456', @@ -66,7 +67,7 @@ export const connectorsMock: Connector[] = [ ], }, }, - isPreconfigured: true, + isPreconfigured: false, }, ]; @@ -75,7 +76,7 @@ export const caseConfigurationResposeMock: CasesConfigureResponse = { created_by: { username: 'elastic', full_name: 'Elastic', email: 'elastic@elastic.co' }, connector_id: '123', connector_name: 'My Connector', - closure_type: 'close-by-user', + closure_type: 'close-by-pushing', updated_at: '2020-04-06T14:03:18.657Z', updated_by: { username: 'elastic', full_name: 'Elastic', email: 'elastic@elastic.co' }, version: 'WzHJ12', @@ -92,7 +93,7 @@ export const caseConfigurationCamelCaseResponseMock: CaseConfigure = { createdBy: { username: 'elastic', fullName: 'Elastic', email: 'elastic@elastic.co' }, connectorId: '123', connectorName: 'My Connector', - closureType: 'close-by-user', + closureType: 'close-by-pushing', updatedAt: '2020-04-06T14:03:18.657Z', updatedBy: { username: 'elastic', fullName: 'Elastic', email: 'elastic@elastic.co' }, version: 'WzHJ12', diff --git a/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.test.tsx b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.test.tsx index 3ee16e19eaf9f..2826e9a2c2e55 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.test.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.test.tsx @@ -5,13 +5,18 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; -import { useCaseConfigure, ReturnUseCaseConfigure, PersistCaseConfigure } from './use_configure'; -import { caseConfigurationCamelCaseResponseMock } from './mock'; +import { + initialState, + useCaseConfigure, + ReturnUseCaseConfigure, + ConnectorConfiguration, +} from './use_configure'; +import { mapping, caseConfigurationCamelCaseResponseMock } from './mock'; import * as api from './api'; jest.mock('./api'); -const configuration: PersistCaseConfigure = { +const configuration: ConnectorConfiguration = { connectorId: '456', connectorName: 'My Connector 2', closureType: 'close-by-pushing', @@ -23,23 +28,20 @@ describe('useConfigure', () => { jest.restoreAllMocks(); }); - const args = { - setConnector: jest.fn(), - setClosureType: jest.fn(), - setCurrentConfiguration: jest.fn(), - }; - test('init', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useCaseConfigure(args) + useCaseConfigure() ); await waitForNextUpdate(); expect(result.current).toEqual({ - loading: true, - persistLoading: false, + ...initialState, refetchCaseConfigure: result.current.refetchCaseConfigure, persistCaseConfigure: result.current.persistCaseConfigure, + setCurrentConfiguration: result.current.setCurrentConfiguration, + setConnector: result.current.setConnector, + setClosureType: result.current.setClosureType, + setMapping: result.current.setMapping, }); }); }); @@ -47,89 +49,64 @@ describe('useConfigure', () => { test('fetch case configuration', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useCaseConfigure(args) + useCaseConfigure() ); await waitForNextUpdate(); await waitForNextUpdate(); expect(result.current).toEqual({ + ...initialState, + closureType: caseConfigurationCamelCaseResponseMock.closureType, + connectorId: caseConfigurationCamelCaseResponseMock.connectorId, + connectorName: caseConfigurationCamelCaseResponseMock.connectorName, + currentConfiguration: { + closureType: caseConfigurationCamelCaseResponseMock.closureType, + connectorId: caseConfigurationCamelCaseResponseMock.connectorId, + connectorName: caseConfigurationCamelCaseResponseMock.connectorName, + }, + version: caseConfigurationCamelCaseResponseMock.version, + firstLoad: true, loading: false, - persistLoading: false, refetchCaseConfigure: result.current.refetchCaseConfigure, persistCaseConfigure: result.current.persistCaseConfigure, + setCurrentConfiguration: result.current.setCurrentConfiguration, + setConnector: result.current.setConnector, + setClosureType: result.current.setClosureType, + setMapping: result.current.setMapping, }); }); }); - test('fetch case configuration - setConnector', async () => { - await act(async () => { - const { waitForNextUpdate } = renderHook(() => - useCaseConfigure(args) - ); - await waitForNextUpdate(); - await waitForNextUpdate(); - expect(args.setConnector).toHaveBeenCalledWith('123', 'My Connector'); - }); - }); - - test('fetch case configuration - setClosureType', async () => { - await act(async () => { - const { waitForNextUpdate } = renderHook(() => - useCaseConfigure(args) - ); - await waitForNextUpdate(); - await waitForNextUpdate(); - expect(args.setClosureType).toHaveBeenCalledWith('close-by-user'); - }); - }); - - test('fetch case configuration - setCurrentConfiguration', async () => { - await act(async () => { - const { waitForNextUpdate } = renderHook(() => - useCaseConfigure(args) - ); - await waitForNextUpdate(); - await waitForNextUpdate(); - expect(args.setCurrentConfiguration).toHaveBeenCalledWith({ - connectorId: '123', - closureType: 'close-by-user', - }); - }); - }); + test('refetch case configuration', async () => { + const spyOnGetCaseConfigure = jest.spyOn(api, 'getCaseConfigure'); - test('fetch case configuration - only setConnector', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useCaseConfigure({ setConnector: jest.fn() }) + useCaseConfigure() ); await waitForNextUpdate(); await waitForNextUpdate(); - expect(result.current).toEqual({ - loading: false, - persistLoading: false, - refetchCaseConfigure: result.current.refetchCaseConfigure, - persistCaseConfigure: result.current.persistCaseConfigure, - }); + result.current.refetchCaseConfigure(); + expect(spyOnGetCaseConfigure).toHaveBeenCalledTimes(2); }); }); - test('refetch case configuration', async () => { - const spyOnGetCaseConfigure = jest.spyOn(api, 'getCaseConfigure'); - + test('correctly sets mappings', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useCaseConfigure(args) + useCaseConfigure() ); await waitForNextUpdate(); await waitForNextUpdate(); - result.current.refetchCaseConfigure(); - expect(spyOnGetCaseConfigure).toHaveBeenCalledTimes(2); + expect(result.current.mapping).toEqual(null); + result.current.setMapping(mapping); + expect(result.current.mapping).toEqual(mapping); }); }); test('set isLoading to true when fetching case configuration', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useCaseConfigure(args) + useCaseConfigure() ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -142,19 +119,12 @@ describe('useConfigure', () => { test('persist case configuration', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useCaseConfigure(args) + useCaseConfigure() ); await waitForNextUpdate(); await waitForNextUpdate(); - result.current.persistCaseConfigure(configuration); - - expect(result.current).toEqual({ - loading: false, - persistLoading: true, - refetchCaseConfigure: result.current.refetchCaseConfigure, - persistCaseConfigure: result.current.persistCaseConfigure, - }); + expect(result.current.persistLoading).toBeTruthy(); }); }); @@ -178,21 +148,16 @@ describe('useConfigure', () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useCaseConfigure(args) + useCaseConfigure() ); await waitForNextUpdate(); await waitForNextUpdate(); result.current.persistCaseConfigure(configuration); + expect(result.current.connectorId).toEqual('123'); await waitForNextUpdate(); - - expect(args.setConnector).toHaveBeenNthCalledWith(2, '456'); - expect(args.setClosureType).toHaveBeenNthCalledWith(2, 'close-by-pushing'); - expect(args.setCurrentConfiguration).toHaveBeenNthCalledWith(2, { - connectorId: '456', - closureType: 'close-by-pushing', - }); + expect(result.current.connectorId).toEqual('456'); }); }); @@ -207,43 +172,16 @@ describe('useConfigure', () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useCaseConfigure(args) - ); - await waitForNextUpdate(); - await waitForNextUpdate(); - - result.current.persistCaseConfigure(configuration); - - await waitForNextUpdate(); - - expect(args.setConnector).toHaveBeenNthCalledWith(2, '456'); - expect(args.setClosureType).toHaveBeenNthCalledWith(2, 'close-by-pushing'); - expect(args.setCurrentConfiguration).toHaveBeenNthCalledWith(2, { - connectorId: '456', - closureType: 'close-by-pushing', - }); - }); - }); - - test('save case configuration - only setConnector', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useCaseConfigure({ setConnector: jest.fn() }) + useCaseConfigure() ); - await waitForNextUpdate(); await waitForNextUpdate(); result.current.persistCaseConfigure(configuration); + expect(result.current.connectorId).toEqual('123'); await waitForNextUpdate(); - - expect(result.current).toEqual({ - loading: false, - persistLoading: false, - refetchCaseConfigure: result.current.refetchCaseConfigure, - persistCaseConfigure: result.current.persistCaseConfigure, - }); + expect(result.current.connectorId).toEqual('456'); }); }); @@ -255,22 +193,34 @@ describe('useConfigure', () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useCaseConfigure(args) + useCaseConfigure() ); await waitForNextUpdate(); await waitForNextUpdate(); expect(result.current).toEqual({ + ...initialState, loading: false, persistLoading: false, refetchCaseConfigure: result.current.refetchCaseConfigure, persistCaseConfigure: result.current.persistCaseConfigure, + setCurrentConfiguration: result.current.setCurrentConfiguration, + setConnector: result.current.setConnector, + setClosureType: result.current.setClosureType, + setMapping: result.current.setMapping, }); }); }); test('unhappy path - persist case configuration', async () => { + const spyOnGetCaseConfigure = jest.spyOn(api, 'getCaseConfigure'); + spyOnGetCaseConfigure.mockImplementation(() => + Promise.resolve({ + ...caseConfigurationCamelCaseResponseMock, + version: '', + }) + ); const spyOnPostCaseConfigure = jest.spyOn(api, 'postCaseConfigure'); spyOnPostCaseConfigure.mockImplementation(() => { throw new Error('Something went wrong'); @@ -278,7 +228,7 @@ describe('useConfigure', () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useCaseConfigure(args) + useCaseConfigure() ); await waitForNextUpdate(); @@ -286,13 +236,24 @@ describe('useConfigure', () => { result.current.persistCaseConfigure(configuration); - await waitForNextUpdate(); - expect(result.current).toEqual({ + ...initialState, + closureType: caseConfigurationCamelCaseResponseMock.closureType, + connectorId: caseConfigurationCamelCaseResponseMock.connectorId, + connectorName: caseConfigurationCamelCaseResponseMock.connectorName, + currentConfiguration: { + closureType: caseConfigurationCamelCaseResponseMock.closureType, + connectorId: caseConfigurationCamelCaseResponseMock.connectorId, + connectorName: caseConfigurationCamelCaseResponseMock.connectorName, + }, + firstLoad: true, loading: false, - persistLoading: false, refetchCaseConfigure: result.current.refetchCaseConfigure, persistCaseConfigure: result.current.persistCaseConfigure, + setCurrentConfiguration: result.current.setCurrentConfiguration, + setConnector: result.current.setConnector, + setClosureType: result.current.setClosureType, + setMapping: result.current.setMapping, }); }); }); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx index 1c03a09a8c2ea..a185d435f7165 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx @@ -4,47 +4,203 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useState, useEffect, useCallback } from 'react'; +import { useEffect, useCallback, useReducer } from 'react'; import { getCaseConfigure, patchCaseConfigure, postCaseConfigure } from './api'; import { useStateToaster, errorToToaster, displaySuccessToast } from '../../../components/toasters'; import * as i18n from './translations'; -import { ClosureType } from './types'; -import { CurrentConfiguration } from '../../../pages/case/components/configure_cases/reducer'; +import { CasesConfigurationMapping, ClosureType } from './types'; -export interface PersistCaseConfigure { +interface Connector { connectorId: string; connectorName: string; +} +export interface ConnectorConfiguration extends Connector { closureType: ClosureType; } -export interface ReturnUseCaseConfigure { +export interface State extends ConnectorConfiguration { + currentConfiguration: ConnectorConfiguration; + firstLoad: boolean; loading: boolean; - refetchCaseConfigure: () => void; + mapping: CasesConfigurationMapping[] | null; + persistLoading: boolean; + version: string; +} +export type Action = + | { + type: 'setCurrentConfiguration'; + currentConfiguration: ConnectorConfiguration; + } + | { + type: 'setConnector'; + connector: Connector; + } + | { + type: 'setLoading'; + payload: boolean; + } + | { + type: 'setFirstLoad'; + payload: boolean; + } + | { + type: 'setPersistLoading'; + payload: boolean; + } + | { + type: 'setVersion'; + payload: string; + } + | { + type: 'setClosureType'; + closureType: ClosureType; + } + | { + type: 'setMapping'; + mapping: CasesConfigurationMapping[]; + }; + +export const configureCasesReducer = (state: State, action: Action) => { + switch (action.type) { + case 'setLoading': + return { + ...state, + loading: action.payload, + }; + case 'setFirstLoad': + return { + ...state, + firstLoad: action.payload, + }; + case 'setPersistLoading': + return { + ...state, + persistLoading: action.payload, + }; + case 'setVersion': + return { + ...state, + version: action.payload, + }; + case 'setCurrentConfiguration': { + return { + ...state, + currentConfiguration: { ...action.currentConfiguration }, + }; + } + case 'setConnector': { + return { + ...state, + ...action.connector, + }; + } + case 'setClosureType': { + return { + ...state, + closureType: action.closureType, + }; + } + case 'setMapping': { + return { + ...state, + mapping: action.mapping, + }; + } + default: + return state; + } +}; + +export interface ReturnUseCaseConfigure extends State { persistCaseConfigure: ({ connectorId, connectorName, closureType, - }: PersistCaseConfigure) => unknown; - persistLoading: boolean; + }: ConnectorConfiguration) => unknown; + refetchCaseConfigure: () => void; + setClosureType: (closureType: ClosureType) => void; + setConnector: (connectorId: string, connectorName?: string) => void; + setCurrentConfiguration: (configuration: ConnectorConfiguration) => void; + setMapping: (newMapping: CasesConfigurationMapping[]) => void; } -interface UseCaseConfigure { - setConnector: (newConnectorId: string, newConnectorName?: string) => void; - setClosureType?: (newClosureType: ClosureType) => void; - setCurrentConfiguration?: (configuration: CurrentConfiguration) => void; -} +export const initialState: State = { + closureType: 'close-by-user', + connectorId: 'none', + connectorName: 'none', + currentConfiguration: { + closureType: 'close-by-user', + connectorId: 'none', + connectorName: 'none', + }, + firstLoad: false, + loading: true, + mapping: null, + persistLoading: false, + version: '', +}; + +export const useCaseConfigure = (): ReturnUseCaseConfigure => { + const [state, dispatch] = useReducer(configureCasesReducer, initialState); + + const setCurrentConfiguration = useCallback((configuration: ConnectorConfiguration) => { + dispatch({ + currentConfiguration: configuration, + type: 'setCurrentConfiguration', + }); + }, []); + + const setConnector = useCallback((connectorId: string, connectorName?: string) => { + dispatch({ + connector: { connectorId, connectorName: connectorName ?? '' }, + type: 'setConnector', + }); + }, []); + + const setClosureType = useCallback((closureType: ClosureType) => { + dispatch({ + closureType, + type: 'setClosureType', + }); + }, []); + + const setMapping = useCallback((newMapping: CasesConfigurationMapping[]) => { + dispatch({ + mapping: newMapping, + type: 'setMapping', + }); + }, []); + + const setLoading = useCallback((isLoading: boolean) => { + dispatch({ + payload: isLoading, + type: 'setLoading', + }); + }, []); + + const setFirstLoad = useCallback((isFirstLoad: boolean) => { + dispatch({ + payload: isFirstLoad, + type: 'setFirstLoad', + }); + }, []); + + const setPersistLoading = useCallback((isPersistLoading: boolean) => { + dispatch({ + payload: isPersistLoading, + type: 'setPersistLoading', + }); + }, []); + + const setVersion = useCallback((version: string) => { + dispatch({ + payload: version, + type: 'setVersion', + }); + }, []); -export const useCaseConfigure = ({ - setConnector, - setClosureType, - setCurrentConfiguration, -}: UseCaseConfigure): ReturnUseCaseConfigure => { const [, dispatchToaster] = useStateToaster(); - const [loading, setLoading] = useState(true); - const [firstLoad, setFirstLoad] = useState(false); - const [persistLoading, setPersistLoading] = useState(false); - const [version, setVersion] = useState(''); const refetchCaseConfigure = useCallback(() => { let didCancel = false; @@ -62,12 +218,13 @@ export const useCaseConfigure = ({ } setVersion(res.version); - if (!firstLoad) { + if (!state.firstLoad) { setFirstLoad(true); if (setCurrentConfiguration != null) { setCurrentConfiguration({ - connectorId: res.connectorId, closureType: res.closureType, + connectorId: res.connectorId, + connectorName: res.connectorName, }); } } @@ -78,9 +235,9 @@ export const useCaseConfigure = ({ if (!didCancel) { setLoading(false); errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, + error: error.body && error.body.message ? new Error(error.body.message) : error, + title: i18n.ERROR_TITLE, }); } } @@ -92,10 +249,10 @@ export const useCaseConfigure = ({ didCancel = true; abortCtrl.abort(); }; - }, []); + }, [state.firstLoad]); const persistCaseConfigure = useCallback( - async ({ connectorId, connectorName, closureType }: PersistCaseConfigure) => { + async ({ connectorId, connectorName, closureType }: ConnectorConfiguration) => { let didCancel = false; const abortCtrl = new AbortController(); const saveCaseConfiguration = async () => { @@ -107,17 +264,17 @@ export const useCaseConfigure = ({ closure_type: closureType, }; const res = - version.length === 0 + state.version.length === 0 ? await postCaseConfigure(connectorObj, abortCtrl.signal) : await patchCaseConfigure( { ...connectorObj, - version, + version: state.version, }, abortCtrl.signal ); if (!didCancel) { - setConnector(res.connectorId); + setConnector(res.connectorId, res.connectorName); if (setClosureType) { setClosureType(res.closureType); } @@ -126,6 +283,7 @@ export const useCaseConfigure = ({ setCurrentConfiguration({ connectorId: res.connectorId, closureType: res.closureType, + connectorName: res.connectorName, }); } @@ -149,7 +307,7 @@ export const useCaseConfigure = ({ abortCtrl.abort(); }; }, - [version] + [state.version] ); useEffect(() => { @@ -157,9 +315,12 @@ export const useCaseConfigure = ({ }, []); return { - loading, + ...state, refetchCaseConfigure, persistCaseConfigure, - persistLoading, + setCurrentConfiguration, + setConnector, + setClosureType, + setMapping, }; }; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/__mock__/index.tsx index 135f0f2a7e26d..e060a94e2d0cd 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/__mock__/index.tsx @@ -4,110 +4,40 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - Connector, - CasesConfigurationMapping, -} from '../../../../../containers/case/configure/types'; -import { State } from '../reducer'; +import { Connector } from '../../../../../containers/case/configure/types'; import { ReturnConnectors } from '../../../../../containers/case/configure/use_connectors'; +import { connectorsMock } from '../../../../../containers/case/configure/mock'; import { ReturnUseCaseConfigure } from '../../../../../containers/case/configure/use_configure'; import { createUseKibanaMock } from '../../../../../mock/kibana_react'; - +export { mapping } from '../../../../../containers/case/configure/mock'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { actionTypeRegistryMock } from '../../../../../../../../../plugins/triggers_actions_ui/public/application/action_type_registry.mock'; -export const connectors: Connector[] = [ - { - id: '123', - actionTypeId: '.servicenow', - name: 'My Connector', - isPreconfigured: false, - config: { - apiUrl: 'https://instance1.service-now.com', - casesConfiguration: { - mapping: [ - { - source: 'title', - target: 'short_description', - actionType: 'overwrite', - }, - { - source: 'description', - target: 'description', - actionType: 'append', - }, - { - source: 'comments', - target: 'comments', - actionType: 'append', - }, - ], - }, - }, - }, - { - id: '456', - actionTypeId: '.servicenow', - name: 'My Connector 2', - isPreconfigured: false, - config: { - apiUrl: 'https://instance2.service-now.com', - casesConfiguration: { - mapping: [ - { - source: 'title', - target: 'short_description', - actionType: 'overwrite', - }, - { - source: 'description', - target: 'description', - actionType: 'overwrite', - }, - { - source: 'comments', - target: 'comments', - actionType: 'append', - }, - ], - }, - }, - }, -]; - -export const mapping: CasesConfigurationMapping[] = [ - { - source: 'title', - target: 'short_description', - actionType: 'overwrite', - }, - { - source: 'description', - target: 'description', - actionType: 'append', - }, - { - source: 'comments', - target: 'comments', - actionType: 'append', - }, -]; +export const connectors: Connector[] = connectorsMock; export const searchURL = '?timerange=(global:(linkTo:!(),timerange:(from:1585487656371,fromStr:now-24h,kind:relative,to:1585574056371,toStr:now)),timeline:(linkTo:!(),timerange:(from:1585227005527,kind:absolute,to:1585313405527)))'; -export const initialState: State = { - connectorId: 'none', - closureType: 'close-by-user', - mapping: null, - currentConfiguration: { connectorId: 'none', closureType: 'close-by-user' }, -}; - export const useCaseConfigureResponse: ReturnUseCaseConfigure = { + closureType: 'close-by-user', + connectorId: 'none', + connectorName: 'none', + currentConfiguration: { + connectorId: 'none', + closureType: 'close-by-user', + connectorName: 'none', + }, + firstLoad: false, loading: false, + mapping: null, + persistCaseConfigure: jest.fn(), persistLoading: false, refetchCaseConfigure: jest.fn(), - persistCaseConfigure: jest.fn(), + setClosureType: jest.fn(), + setConnector: jest.fn(), + setCurrentConfiguration: jest.fn(), + setMapping: jest.fn(), + version: '', }; export const useConnectorsResponse: ReturnConnectors = { diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.test.tsx index 5ea3f500c0349..2f9761d733b5b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.test.tsx @@ -4,9 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect } from 'react'; +import React from 'react'; import { ReactWrapper, mount } from 'enzyme'; +import { ConfigureCases } from './'; +import { TestProviders } from '../../../../mock'; +import { Connectors } from './connectors'; +import { ClosureOptions } from './closure_options'; +import { Mapping } from './mapping'; +import { + ActionsConnectorsContextProvider, + ConnectorAddFlyout, + ConnectorEditFlyout, +} from '../../../../../../../../plugins/triggers_actions_ui/public'; +import { EuiBottomBar } from '@elastic/eui'; + import { useKibana } from '../../../../lib/kibana'; import { useConnectors } from '../../../../containers/case/configure/use_connectors'; import { useCaseConfigure } from '../../../../containers/case/configure/use_configure'; @@ -18,6 +30,7 @@ import { useCaseConfigureResponse, useConnectorsResponse, kibanaMockImplementationArgs, + mapping, } from './__mock__'; jest.mock('../../../../lib/kibana'); @@ -29,720 +42,741 @@ const useKibanaMock = useKibana as jest.Mock; const useConnectorsMock = useConnectors as jest.Mock; const useCaseConfigureMock = useCaseConfigure as jest.Mock; const useGetUrlSearchMock = useGetUrlSearch as jest.Mock; - -import { ConfigureCases } from './'; -import { TestProviders } from '../../../../mock'; -import { Connectors } from './connectors'; -import { ClosureOptions } from './closure_options'; -import { Mapping } from './mapping'; -import { - ActionsConnectorsContextProvider, - ConnectorAddFlyout, - ConnectorEditFlyout, -} from '../../../../../../../../plugins/triggers_actions_ui/public'; -import { EuiBottomBar } from '@elastic/eui'; - -describe('rendering', () => { - let wrapper: ReactWrapper; - beforeEach(() => { - jest.resetAllMocks(); - useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); - useConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, connectors: [] })); - useKibanaMock.mockImplementation(() => kibanaMockImplementationArgs); - useGetUrlSearchMock.mockImplementation(() => searchURL); - - wrapper = mount(, { wrappingComponent: TestProviders }); - }); - - test('it renders the Connectors', () => { - expect(wrapper.find('[data-test-subj="case-connectors-form-group"]').exists()).toBeTruthy(); - }); - - test('it renders the ClosureType', () => { - expect( - wrapper.find('[data-test-subj="case-closure-options-form-group"]').exists() - ).toBeTruthy(); - }); - - test('it renders the Mapping', () => { - expect(wrapper.find('[data-test-subj="case-mapping-form-group"]').exists()).toBeTruthy(); - }); - - test('it renders the ActionsConnectorsContextProvider', () => { - // Components from triggers_actions_ui do not have a data-test-subj - expect(wrapper.find(ActionsConnectorsContextProvider).exists()).toBeTruthy(); - }); - - test('it renders the ConnectorAddFlyout', () => { - // Components from triggers_actions_ui do not have a data-test-subj - expect(wrapper.find(ConnectorAddFlyout).exists()).toBeTruthy(); - }); - - test('it does NOT render the ConnectorEditFlyout', () => { - // Components from triggers_actions_ui do not have a data-test-subj - expect(wrapper.find(ConnectorEditFlyout).exists()).toBeFalsy(); - }); - - test('it does NOT render the EuiCallOut', () => { - expect(wrapper.find('[data-test-subj="configure-cases-warning-callout"]').exists()).toBeFalsy(); - }); - - test('it does NOT render the EuiBottomBar', () => { - expect( - wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists() - ).toBeFalsy(); - }); -}); - -describe('ConfigureCases - Unhappy path', () => { - beforeEach(() => { - jest.resetAllMocks(); - useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); - useConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, connectors: [] })); - useKibanaMock.mockImplementation(() => kibanaMockImplementationArgs); - useGetUrlSearchMock.mockImplementation(() => searchURL); - }); - - test('it shows the warning callout when configuration is invalid', () => { - useCaseConfigureMock.mockImplementation( - ({ setConnector, setClosureType, setCurrentConfiguration }) => { - useEffect(() => setConnector('not-id'), []); - return useCaseConfigureResponse; - } - ); - - const wrapper = mount(, { wrappingComponent: TestProviders }); - - expect( - wrapper.find('[data-test-subj="configure-cases-warning-callout"]').exists() - ).toBeTruthy(); - }); -}); - -describe('ConfigureCases - Happy path', () => { - let wrapper: ReactWrapper; - - beforeEach(() => { - jest.resetAllMocks(); - useCaseConfigureMock.mockImplementation( - ({ setConnector, setClosureType, setCurrentConfiguration }) => { - useEffect(() => setConnector('123'), []); - useEffect(() => setClosureType('close-by-user'), []); - useEffect( - () => - setCurrentConfiguration({ - connectorId: '123', - closureType: 'close-by-user', - }), - [] - ); - return useCaseConfigureResponse; - } - ); - useConnectorsMock.mockImplementation(() => useConnectorsResponse); - useKibanaMock.mockImplementation(() => kibanaMockImplementationArgs); - useGetUrlSearchMock.mockImplementation(() => searchURL); - - wrapper = mount(, { wrappingComponent: TestProviders }); - }); - - test('it renders the ConnectorEditFlyout', () => { - expect(wrapper.find(ConnectorEditFlyout).exists()).toBeTruthy(); - }); - - test('it renders with correct props', () => { - // Connector - expect(wrapper.find(Connectors).prop('connectors')).toEqual(connectors); - expect(wrapper.find(Connectors).prop('disabled')).toBe(false); - expect(wrapper.find(Connectors).prop('isLoading')).toBe(false); - expect(wrapper.find(Connectors).prop('selectedConnector')).toBe('123'); - - // ClosureOptions - expect(wrapper.find(ClosureOptions).prop('disabled')).toBe(false); - expect(wrapper.find(ClosureOptions).prop('closureTypeSelected')).toBe('close-by-user'); - - // Mapping - expect(wrapper.find(Mapping).prop('disabled')).toBe(true); - expect(wrapper.find(Mapping).prop('updateConnectorDisabled')).toBe(false); - expect(wrapper.find(Mapping).prop('mapping')).toEqual( - connectors[0].config.casesConfiguration.mapping - ); - - // Flyouts - expect(wrapper.find(ConnectorAddFlyout).prop('addFlyoutVisible')).toBe(false); - expect(wrapper.find(ConnectorAddFlyout).prop('actionTypes')).toEqual([ - { - id: '.servicenow', - name: 'ServiceNow', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'platinum', - }, - ]); - - expect(wrapper.find(ConnectorEditFlyout).prop('editFlyoutVisible')).toBe(false); - expect(wrapper.find(ConnectorEditFlyout).prop('initialConnector')).toEqual(connectors[0]); - }); - - test('it disables correctly when the user cannot crud', () => { - const newWrapper = mount(, { - wrappingComponent: TestProviders, +describe('ConfigureCases', () => { + describe('rendering', () => { + let wrapper: ReactWrapper; + beforeEach(() => { + jest.resetAllMocks(); + useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); + useConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, connectors: [] })); + useKibanaMock.mockImplementation(() => kibanaMockImplementationArgs); + useGetUrlSearchMock.mockImplementation(() => searchURL); + + wrapper = mount(, { wrappingComponent: TestProviders }); }); - expect(newWrapper.find(Connectors).prop('disabled')).toBe(true); - expect(newWrapper.find(ClosureOptions).prop('disabled')).toBe(true); - expect(newWrapper.find(Mapping).prop('disabled')).toBe(true); - expect(newWrapper.find(Mapping).prop('updateConnectorDisabled')).toBe(true); - }); - - test('it disables correctly Connector when loading connectors', () => { - useConnectorsMock.mockImplementation(() => ({ - ...useConnectorsResponse, - loading: true, - })); - - const newWrapper = mount(, { wrappingComponent: TestProviders }); - - expect(newWrapper.find(Connectors).prop('disabled')).toBe(true); - }); - - test('it disables correctly Connector when saving configuration', () => { - useCaseConfigureMock.mockImplementation(() => ({ - ...useCaseConfigureResponse, - persistLoading: true, - })); - - const newWrapper = mount(, { wrappingComponent: TestProviders }); - - expect(newWrapper.find(Connectors).prop('disabled')).toBe(true); - }); + test('it renders the Connectors', () => { + expect(wrapper.find('[data-test-subj="case-connectors-form-group"]').exists()).toBeTruthy(); + }); - test('it pass the correct value to isLoading attribute on Connector', () => { - useConnectorsMock.mockImplementation(() => ({ - ...useConnectorsResponse, - loading: true, - })); + test('it renders the ClosureType', () => { + expect( + wrapper.find('[data-test-subj="case-closure-options-form-group"]').exists() + ).toBeTruthy(); + }); - const newWrapper = mount(, { wrappingComponent: TestProviders }); + test('it renders the Mapping', () => { + expect(wrapper.find('[data-test-subj="case-mapping-form-group"]').exists()).toBeTruthy(); + }); - expect(newWrapper.find(Connectors).prop('isLoading')).toBe(true); - }); + test('it renders the ActionsConnectorsContextProvider', () => { + // Components from triggers_actions_ui do not have a data-test-subj + expect(wrapper.find(ActionsConnectorsContextProvider).exists()).toBeTruthy(); + }); - test('it set correctly the selected connector', () => { - useCaseConfigureMock.mockImplementation( - ({ setConnector, setClosureType, setCurrentConfiguration }) => { - useEffect(() => setConnector('456'), []); - return useCaseConfigureResponse; - } - ); + test('it renders the ConnectorAddFlyout', () => { + // Components from triggers_actions_ui do not have a data-test-subj + expect(wrapper.find(ConnectorAddFlyout).exists()).toBeTruthy(); + }); - const newWrapper = mount(, { wrappingComponent: TestProviders }); + test('it does NOT render the ConnectorEditFlyout', () => { + // Components from triggers_actions_ui do not have a data-test-subj + expect(wrapper.find(ConnectorEditFlyout).exists()).toBeFalsy(); + }); - expect(newWrapper.find(Connectors).prop('selectedConnector')).toBe('456'); - }); + test('it does NOT render the EuiCallOut', () => { + expect( + wrapper.find('[data-test-subj="configure-cases-warning-callout"]').exists() + ).toBeFalsy(); + }); - test('it show the add flyout when pressing the add connector button', () => { - wrapper.find('button[data-test-subj="case-configure-add-connector-button"]').simulate('click'); - wrapper.update(); + test('it does NOT render the EuiBottomBar', () => { + expect( + wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists() + ).toBeFalsy(); + }); - expect(wrapper.find(ConnectorAddFlyout).prop('addFlyoutVisible')).toBe(true); - expect(wrapper.find(EuiBottomBar).exists()).toBeFalsy(); + test('it disables correctly ClosureOptions when the connector is set to none', () => { + expect(wrapper.find(ClosureOptions).prop('disabled')).toBe(true); + }); }); - test('it disables correctly ClosureOptions when loading connectors', () => { - useConnectorsMock.mockImplementation(() => ({ - ...useConnectorsResponse, - loading: true, - })); + describe('Unhappy path', () => { + let wrapper: ReactWrapper; + beforeEach(() => { + jest.resetAllMocks(); + useCaseConfigureMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + closureType: 'close-by-user', + connectorId: 'not-id', + connectorName: 'unchanged', + currentConfiguration: { + connectorName: 'unchanged', + connectorId: 'not-id', + closureType: 'close-by-user', + }, + })); + useConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, connectors: [] })); + useKibanaMock.mockImplementation(() => kibanaMockImplementationArgs); + useGetUrlSearchMock.mockImplementation(() => searchURL); + wrapper = mount(, { wrappingComponent: TestProviders }); + }); - const newWrapper = mount(, { wrappingComponent: TestProviders }); + test('it shows the warning callout when configuration is invalid', () => { + expect( + wrapper.find('[data-test-subj="configure-cases-warning-callout"]').exists() + ).toBeTruthy(); + }); - expect(newWrapper.find(ClosureOptions).prop('disabled')).toBe(true); + test('it disables the update connector button when the connectorId is invalid', () => { + expect(wrapper.find(Mapping).prop('disabled')).toBe(true); + }); }); - test('it disables correctly ClosureOptions when saving configuration', () => { - useCaseConfigureMock.mockImplementation(() => ({ - ...useCaseConfigureResponse, - persistLoading: true, - })); + describe('Happy path', () => { + let wrapper: ReactWrapper; + + beforeEach(() => { + jest.resetAllMocks(); + useCaseConfigureMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + mapping, + closureType: 'close-by-user', + connectorId: '123', + connectorName: 'unchanged', + currentConfiguration: { + connectorName: 'unchanged', + connectorId: '123', + closureType: 'close-by-user', + }, + })); + useConnectorsMock.mockImplementation(() => useConnectorsResponse); + useKibanaMock.mockImplementation(() => kibanaMockImplementationArgs); + useGetUrlSearchMock.mockImplementation(() => searchURL); + + wrapper = mount(, { wrappingComponent: TestProviders }); + }); - const newWrapper = mount(, { wrappingComponent: TestProviders }); + test('it renders the ConnectorEditFlyout', () => { + expect(wrapper.find(ConnectorEditFlyout).exists()).toBeTruthy(); + }); - expect(newWrapper.find(ClosureOptions).prop('disabled')).toBe(true); - }); + test('it renders with correct props', () => { + // Connector + expect(wrapper.find(Connectors).prop('connectors')).toEqual(connectors); + expect(wrapper.find(Connectors).prop('disabled')).toBe(false); + expect(wrapper.find(Connectors).prop('isLoading')).toBe(false); + expect(wrapper.find(Connectors).prop('selectedConnector')).toBe('123'); + + // ClosureOptions + expect(wrapper.find(ClosureOptions).prop('disabled')).toBe(false); + expect(wrapper.find(ClosureOptions).prop('closureTypeSelected')).toBe('close-by-user'); + + // Mapping + expect(wrapper.find(Mapping).prop('disabled')).toBe(true); + expect(wrapper.find(Mapping).prop('updateConnectorDisabled')).toBe(false); + expect(wrapper.find(Mapping).prop('mapping')).toEqual( + connectors[0].config.casesConfiguration.mapping + ); + + // Flyouts + expect(wrapper.find(ConnectorAddFlyout).prop('addFlyoutVisible')).toBe(false); + expect(wrapper.find(ConnectorAddFlyout).prop('actionTypes')).toEqual([ + { + id: '.servicenow', + name: 'ServiceNow', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'platinum', + }, + ]); + + expect(wrapper.find(ConnectorEditFlyout).prop('editFlyoutVisible')).toBe(false); + expect(wrapper.find(ConnectorEditFlyout).prop('initialConnector')).toEqual(connectors[0]); + }); - test('it disables correctly ClosureOptions when the connector is set to none', () => { - useCaseConfigureMock.mockImplementation( - ({ setConnector, setClosureType, setCurrentConfiguration }) => { - useEffect(() => setConnector('none'), []); - return useCaseConfigureResponse; - } - ); + test('it does not shows the action bar when there is no change', () => { + expect( + wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists() + ).toBeFalsy(); + }); - const newWrapper = mount(, { wrappingComponent: TestProviders }); + test('it disables the mapping permanently', () => { + expect(wrapper.find(Mapping).prop('disabled')).toBe(true); + }); - expect(newWrapper.find(ClosureOptions).prop('disabled')).toBe(true); - }); + test('it sets the mapping of a connector correctly', () => { + expect(wrapper.find(Mapping).prop('mapping')).toEqual( + connectors[0].config.casesConfiguration.mapping + ); + }); - test('it disables the mapping permanently', () => { - expect(wrapper.find(Mapping).prop('disabled')).toBe(true); - }); + // TODO: When mapping is enabled the test.todo should be implemented. + test.todo('the mapping is changed successfully when changing the third party'); + test.todo('the mapping is changed successfully when changing the action type'); + test.todo('it disables the update connector button when loading the configuration'); - test('it disables the update connector button when loading the connectors', () => { - useConnectorsMock.mockImplementation(() => ({ - ...useConnectorsResponse, - loading: true, - })); + test('it disables correctly when the user cannot crud', () => { + const newWrapper = mount(, { + wrappingComponent: TestProviders, + }); - expect(wrapper.find(Mapping).prop('disabled')).toBe(true); + expect(newWrapper.find(Connectors).prop('disabled')).toBe(true); + expect(newWrapper.find(ClosureOptions).prop('disabled')).toBe(true); + expect(newWrapper.find(Mapping).prop('disabled')).toBe(true); + expect(newWrapper.find(Mapping).prop('updateConnectorDisabled')).toBe(true); + }); }); - test('it disables the update connector button when loading the configuration', () => { - useCaseConfigureMock.mockImplementation(() => ({ - ...useCaseConfigureResponse, - loading: true, - })); - - const newWrapper = mount(, { wrappingComponent: TestProviders }); + describe('loading connectors', () => { + let wrapper: ReactWrapper; + + beforeEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + jest.clearAllMocks(); + useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); + useConnectorsMock.mockImplementation(() => ({ + ...useConnectorsResponse, + loading: true, + })); + useKibanaMock.mockImplementation(() => kibanaMockImplementationArgs); + useGetUrlSearchMock.mockImplementation(() => searchURL); + wrapper = mount(, { wrappingComponent: TestProviders }); + }); - expect(newWrapper.find(Mapping).prop('disabled')).toBe(true); - }); + test('it disables correctly Connector when loading connectors', () => { + expect(wrapper.find(Connectors).prop('disabled')).toBe(true); + }); - test('it disables the update connector button when saving the configuration', () => { - useCaseConfigureMock.mockImplementation(() => ({ - ...useCaseConfigureResponse, - persistLoading: true, - })); + test('it pass the correct value to isLoading attribute on Connector', () => { + expect(wrapper.find(Connectors).prop('isLoading')).toBe(true); + }); - const newWrapper = mount(, { wrappingComponent: TestProviders }); + test('it disables correctly ClosureOptions when loading connectors', () => { + expect(wrapper.find(ClosureOptions).prop('disabled')).toBe(true); + }); - expect(newWrapper.find(Mapping).prop('disabled')).toBe(true); + test('it disables the update connector button when loading the connectors', () => { + expect(wrapper.find(Mapping).prop('disabled')).toBe(true); + }); + test('it disables the buttons of action bar when loading connectors', () => { + useCaseConfigureMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + mapping, + closureType: 'close-by-user', + connectorId: '456', + connectorName: 'unchanged', + currentConfiguration: { + connectorName: 'unchanged', + connectorId: '123', + closureType: 'close-by-user', + }, + })); + const newWrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect( + newWrapper + .find('[data-test-subj="case-configure-action-bottom-bar-cancel-button"]') + .first() + .prop('isDisabled') + ).toBe(true); + + expect( + newWrapper + .find('[data-test-subj="case-configure-action-bottom-bar-save-button"]') + .first() + .prop('isDisabled') + ).toBe(true); + }); }); - test('it disables the update connector button when the connectorId is invalid', () => { - useCaseConfigureMock.mockImplementation( - ({ setConnector, setClosureType, setCurrentConfiguration }) => { - useEffect(() => setConnector('not-id'), []); - return useCaseConfigureResponse; - } - ); + describe('saving configuration', () => { + let wrapper: ReactWrapper; - const newWrapper = mount(, { wrappingComponent: TestProviders }); + beforeEach(() => { + jest.resetAllMocks(); + useCaseConfigureMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + persistLoading: true, + })); - expect(newWrapper.find(Mapping).prop('disabled')).toBe(true); - }); - - test('it disables the update connector button when the connectorId is set to none', () => { - useCaseConfigureMock.mockImplementation( - ({ setConnector, setClosureType, setCurrentConfiguration }) => { - useEffect(() => setConnector('none'), []); - return useCaseConfigureResponse; - } - ); + useConnectorsMock.mockImplementation(() => useConnectorsResponse); + useKibanaMock.mockImplementation(() => kibanaMockImplementationArgs); + useGetUrlSearchMock.mockImplementation(() => searchURL); + wrapper = mount(, { wrappingComponent: TestProviders }); + }); - const newWrapper = mount(, { wrappingComponent: TestProviders }); + test('it disables correctly Connector when saving configuration', () => { + expect(wrapper.find(Connectors).prop('disabled')).toBe(true); + }); - expect(newWrapper.find(Mapping).prop('disabled')).toBe(true); - }); + test('it disables correctly ClosureOptions when saving configuration', () => { + expect(wrapper.find(ClosureOptions).prop('disabled')).toBe(true); + }); - test('it show the edit flyout when pressing the update connector button', () => { - wrapper.find('button[data-test-subj="case-mapping-update-connector-button"]').simulate('click'); - wrapper.update(); + test('it disables the update connector button when saving the configuration', () => { + expect(wrapper.find(Mapping).prop('disabled')).toBe(true); + }); - expect(wrapper.find(ConnectorEditFlyout).prop('editFlyoutVisible')).toBe(true); - expect(wrapper.find(EuiBottomBar).exists()).toBeFalsy(); - }); + test('it disables the buttons of action bar when saving configuration', () => { + useCaseConfigureMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + mapping, + closureType: 'close-by-user', + connectorId: '456', + connectorName: 'unchanged', + currentConfiguration: { + connectorName: 'unchanged', + connectorId: '123', + closureType: 'close-by-user', + }, + persistLoading: true, + })); + + const newWrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect( + newWrapper + .find('[data-test-subj="case-configure-action-bottom-bar-cancel-button"]') + .first() + .prop('isDisabled') + ).toBe(true); + + expect( + newWrapper + .find('[data-test-subj="case-configure-action-bottom-bar-save-button"]') + .first() + .prop('isDisabled') + ).toBe(true); + }); - test('it sets the mapping of a connector correctly', () => { - expect(wrapper.find(Mapping).prop('mapping')).toEqual( - connectors[0].config.casesConfiguration.mapping - ); + test('it shows the loading spinner when saving configuration', () => { + useCaseConfigureMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + mapping, + closureType: 'close-by-user', + connectorId: '456', + connectorName: 'unchanged', + currentConfiguration: { + connectorName: 'unchanged', + connectorId: '123', + closureType: 'close-by-user', + }, + persistLoading: true, + })); + + const newWrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect( + newWrapper + .find('[data-test-subj="case-configure-action-bottom-bar-cancel-button"]') + .first() + .prop('isLoading') + ).toBe(true); + + expect( + newWrapper + .find('[data-test-subj="case-configure-action-bottom-bar-save-button"]') + .first() + .prop('isLoading') + ).toBe(true); + }); }); - // TODO: When mapping is enabled the test.todo should be implemented. - test.todo('the mapping is changed successfully when changing the third party'); - test.todo('the mapping is changed successfully when changing the action type'); - - test('it does not shows the action bar when there is no change', () => { - expect( - wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists() - ).toBeFalsy(); - }); + describe('update connector', () => { + let wrapper: ReactWrapper; + const persistCaseConfigure = jest.fn(); - test('it shows the action bar when the connector is changed', () => { - wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); - wrapper.update(); - wrapper.find('button[data-test-subj="dropdown-connector-456"]').simulate('click'); - wrapper.update(); + beforeEach(() => { + jest.resetAllMocks(); + useCaseConfigureMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + mapping, + closureType: 'close-by-user', + connectorId: '456', + connectorName: 'unchanged', + currentConfiguration: { + connectorName: 'unchanged', + connectorId: '123', + closureType: 'close-by-user', + }, + persistCaseConfigure, + })); + useConnectorsMock.mockImplementation(() => useConnectorsResponse); + useKibanaMock.mockImplementation(() => kibanaMockImplementationArgs); + useGetUrlSearchMock.mockImplementation(() => searchURL); + + wrapper = mount(, { wrappingComponent: TestProviders }); + }); - expect( - wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists() - ).toBeTruthy(); - expect( + test('it submits the configuration correctly', () => { wrapper - .find('[data-test-subj="case-configure-action-bottom-bar-total-changes"]') + .find('[data-test-subj="case-configure-action-bottom-bar-save-button"]') .first() - .text() - ).toBe('1 unsaved changes'); - }); - - test('it shows the action bar when the closure type is changed', () => { - wrapper.find('input[id="close-by-pushing"]').simulate('change'); - wrapper.update(); + .simulate('click'); - expect( - wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists() - ).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="case-configure-action-bottom-bar-total-changes"]') - .first() - .text() - ).toBe('1 unsaved changes'); - }); + wrapper.update(); - test('it tracks the changes successfully', () => { - wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); - wrapper.update(); - wrapper.find('button[data-test-subj="dropdown-connector-456"]').simulate('click'); - wrapper.update(); - wrapper.find('input[id="close-by-pushing"]').simulate('change'); - wrapper.update(); - - expect( - wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists() - ).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="case-configure-action-bottom-bar-total-changes"]') - .first() - .text() - ).toBe('2 unsaved changes'); - }); + expect(persistCaseConfigure).toHaveBeenCalled(); + expect(persistCaseConfigure).toHaveBeenCalledWith({ + connectorId: '456', + connectorName: 'My Connector 2', + closureType: 'close-by-user', + }); + }); - test('it tracks and reverts the changes successfully ', () => { - // change settings - wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); - wrapper.update(); - wrapper.find('button[data-test-subj="dropdown-connector-456"]').simulate('click'); - wrapper.update(); - wrapper.find('input[id="close-by-pushing"]').simulate('change'); - wrapper.update(); - - // revert back to initial settings - wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); - wrapper.update(); - wrapper.find('button[data-test-subj="dropdown-connector-123"]').simulate('click'); - wrapper.update(); - wrapper.find('input[id="close-by-user"]').simulate('change'); - wrapper.update(); - - expect( - wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists() - ).toBeFalsy(); + test('it has the correct url on cancel button', () => { + expect( + wrapper + .find('[data-test-subj="case-configure-action-bottom-bar-cancel-button"]') + .first() + .prop('href') + ).toBe(`#/link-to/case${searchURL}`); + }); + test('it disables the buttons of action bar when loading configuration', () => { + useCaseConfigureMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + mapping, + closureType: 'close-by-user', + connectorId: '456', + connectorName: 'unchanged', + currentConfiguration: { + connectorName: 'unchanged', + connectorId: '123', + closureType: 'close-by-user', + }, + loading: true, + })); + const newWrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect( + newWrapper + .find('[data-test-subj="case-configure-action-bottom-bar-cancel-button"]') + .first() + .prop('isDisabled') + ).toBe(true); + + expect( + newWrapper + .find('[data-test-subj="case-configure-action-bottom-bar-save-button"]') + .first() + .prop('isDisabled') + ).toBe(true); + }); }); - test('it close and restores the action bar when the add connector button is pressed', () => { - // Change closure type - wrapper.find('input[id="close-by-pushing"]').simulate('change'); - wrapper.update(); + describe('user interactions', () => { + beforeEach(() => { + jest.resetAllMocks(); + useCaseConfigureMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + mapping, + closureType: 'close-by-user', + connectorId: '456', + connectorName: 'unchanged', + currentConfiguration: { + connectorName: 'unchanged', + connectorId: '456', + closureType: 'close-by-user', + }, + })); + useConnectorsMock.mockImplementation(() => useConnectorsResponse); + useKibanaMock.mockImplementation(() => kibanaMockImplementationArgs); + useGetUrlSearchMock.mockImplementation(() => searchURL); + }); - // Press add connector button - wrapper.find('button[data-test-subj="case-configure-add-connector-button"]').simulate('click'); - wrapper.update(); + test('it show the add flyout when pressing the add connector button', () => { + const wrapper = mount(, { wrappingComponent: TestProviders }); + wrapper + .find('button[data-test-subj="case-configure-add-connector-button"]') + .simulate('click'); + wrapper.update(); - expect( - wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists() - ).toBeFalsy(); + expect(wrapper.find(ConnectorAddFlyout).prop('addFlyoutVisible')).toBe(true); + expect(wrapper.find(EuiBottomBar).exists()).toBeFalsy(); + }); - expect(wrapper.find(ConnectorAddFlyout).prop('addFlyoutVisible')).toBe(true); + test('it show the edit flyout when pressing the update connector button', () => { + const wrapper = mount(, { wrappingComponent: TestProviders }); + wrapper + .find('button[data-test-subj="case-mapping-update-connector-button"]') + .simulate('click'); + wrapper.update(); - // Close the add flyout - wrapper.find('button[data-test-subj="euiFlyoutCloseButton"]').simulate('click'); - wrapper.update(); + expect(wrapper.find(ConnectorEditFlyout).prop('editFlyoutVisible')).toBe(true); + expect(wrapper.find(EuiBottomBar).exists()).toBeFalsy(); + }); - expect( - wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists() - ).toBeTruthy(); + test('it tracks the changes successfully', () => { + useCaseConfigureMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + mapping, + closureType: 'close-by-user', + connectorId: '456', + connectorName: 'unchanged', + currentConfiguration: { + connectorName: 'unchanged', + connectorId: '123', + closureType: 'close-by-pushing', + }, + })); + const wrapper = mount(, { wrappingComponent: TestProviders }); + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.update(); + wrapper.find('button[data-test-subj="dropdown-connector-456"]').simulate('click'); + wrapper.update(); + wrapper.find('input[id="close-by-pushing"]').simulate('change'); + wrapper.update(); + + expect( + wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists() + ).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="case-configure-action-bottom-bar-total-changes"]') + .first() + .text() + ).toBe('2 unsaved changes'); + }); + test('it tracks the changes successfully when name changes', () => { + useCaseConfigureMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + mapping, + closureType: 'close-by-user', + connectorId: '456', + connectorName: 'nameChange', + currentConfiguration: { + connectorId: '123', + closureType: 'close-by-pushing', + connectorName: 'before', + }, + })); + const wrapper = mount(, { wrappingComponent: TestProviders }); + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.update(); + wrapper.find('button[data-test-subj="dropdown-connector-456"]').simulate('click'); + wrapper.update(); + wrapper.find('input[id="close-by-pushing"]').simulate('change'); + wrapper.update(); + + expect( + wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists() + ).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="case-configure-action-bottom-bar-total-changes"]') + .first() + .text() + ).toBe('2 unsaved changes'); + }); - expect(wrapper.find(ConnectorAddFlyout).prop('addFlyoutVisible')).toBe(false); + test('it tracks and reverts the changes successfully ', () => { + const wrapper = mount(, { wrappingComponent: TestProviders }); + // change settings + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.update(); + wrapper.find('button[data-test-subj="dropdown-connector-456"]').simulate('click'); + wrapper.update(); + wrapper.find('input[id="close-by-pushing"]').simulate('change'); + wrapper.update(); + + // revert back to initial settings + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.update(); + wrapper.find('button[data-test-subj="dropdown-connector-123"]').simulate('click'); + wrapper.update(); + wrapper.find('input[id="close-by-user"]').simulate('change'); + wrapper.update(); + + expect( + wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists() + ).toBeFalsy(); + }); - expect( + test('it close and restores the action bar when the add connector button is pressed', () => { + useCaseConfigureMock + .mockImplementationOnce(() => ({ + ...useCaseConfigureResponse, + mapping, + closureType: 'close-by-user', + connectorId: '456', + currentConfiguration: { connectorId: '456', closureType: 'close-by-user' }, + })) + .mockImplementation(() => ({ + ...useCaseConfigureResponse, + mapping, + closureType: 'close-by-pushing', + connectorId: '456', + currentConfiguration: { connectorId: '456', closureType: 'close-by-user' }, + })); + const wrapper = mount(, { wrappingComponent: TestProviders }); + // Change closure type + wrapper.find('input[id="close-by-pushing"]').simulate('change'); + wrapper.update(); + + expect( + wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists() + ).toBeTruthy(); + + // Press add connector button wrapper - .find('[data-test-subj="case-configure-action-bottom-bar-total-changes"]') - .first() - .text() - ).toBe('1 unsaved changes'); - }); + .find('button[data-test-subj="case-configure-add-connector-button"]') + .simulate('click'); + wrapper.update(); - test('it close and restores the action bar when the update connector button is pressed', () => { - // Change closure type - wrapper.find('input[id="close-by-pushing"]').simulate('change'); - wrapper.update(); + expect( + wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists() + ).toBeFalsy(); - // Press update connector button - wrapper.find('button[data-test-subj="case-mapping-update-connector-button"]').simulate('click'); - wrapper.update(); + expect(wrapper.find(ConnectorAddFlyout).prop('addFlyoutVisible')).toBe(true); - expect( - wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists() - ).toBeFalsy(); + // Close the add flyout + wrapper.find('button[data-test-subj="euiFlyoutCloseButton"]').simulate('click'); + wrapper.update(); - expect(wrapper.find(ConnectorEditFlyout).prop('editFlyoutVisible')).toBe(true); + expect( + wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists() + ).toBeTruthy(); - // Close the edit flyout - wrapper.find('button[data-test-subj="euiFlyoutCloseButton"]').simulate('click'); - wrapper.update(); + expect(wrapper.find(ConnectorAddFlyout).prop('addFlyoutVisible')).toBe(false); - expect( - wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists() - ).toBeTruthy(); - - expect(wrapper.find(ConnectorEditFlyout).prop('editFlyoutVisible')).toBe(false); + expect( + wrapper + .find('[data-test-subj="case-configure-action-bottom-bar-total-changes"]') + .first() + .text() + ).toBe('1 unsaved changes'); + }); - expect( + test('it close and restores the action bar when the update connector button is pressed', () => { + useCaseConfigureMock + .mockImplementationOnce(() => ({ + ...useCaseConfigureResponse, + mapping, + closureType: 'close-by-user', + connectorId: '456', + currentConfiguration: { connectorId: '456', closureType: 'close-by-user' }, + })) + .mockImplementation(() => ({ + ...useCaseConfigureResponse, + mapping, + closureType: 'close-by-pushing', + connectorId: '456', + currentConfiguration: { connectorId: '456', closureType: 'close-by-user' }, + })); + const wrapper = mount(, { wrappingComponent: TestProviders }); + + // Change closure type + wrapper.find('input[id="close-by-pushing"]').simulate('change'); + wrapper.update(); + + // Press update connector button wrapper - .find('[data-test-subj="case-configure-action-bottom-bar-total-changes"]') - .first() - .text() - ).toBe('1 unsaved changes'); - }); + .find('button[data-test-subj="case-mapping-update-connector-button"]') + .simulate('click'); + wrapper.update(); - test('it disables the buttons of action bar when loading connectors', () => { - useCaseConfigureMock.mockImplementation( - ({ setConnector, setClosureType, setCurrentConfiguration }) => { - useEffect(() => setConnector('456'), []); - useEffect(() => setClosureType('close-by-user'), []); - useEffect( - () => - setCurrentConfiguration({ - connectorId: '123', - closureType: 'close-by-user', - }), - [] - ); - return useCaseConfigureResponse; - } - ); - - useConnectorsMock.mockImplementation(() => ({ - ...useConnectorsResponse, - loading: true, - })); - - const newWrapper = mount(, { wrappingComponent: TestProviders }); - - expect( - newWrapper - .find('[data-test-subj="case-configure-action-bottom-bar-cancel-button"]') - .first() - .prop('isDisabled') - ).toBe(true); + expect( + wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists() + ).toBeFalsy(); - expect( - newWrapper - .find('[data-test-subj="case-configure-action-bottom-bar-save-button"]') - .first() - .prop('isDisabled') - ).toBe(true); - }); + expect(wrapper.find(ConnectorEditFlyout).prop('editFlyoutVisible')).toBe(true); - test('it disables the buttons of action bar when loading configuration', () => { - useCaseConfigureMock.mockImplementation( - ({ setConnector, setClosureType, setCurrentConfiguration }) => { - useEffect(() => setConnector('456'), []); - useEffect(() => setClosureType('close-by-user'), []); - useEffect( - () => - setCurrentConfiguration({ - connectorId: '123', - closureType: 'close-by-user', - }), - [] - ); - return { ...useCaseConfigureResponse, loading: true }; - } - ); - - const newWrapper = mount(, { wrappingComponent: TestProviders }); - - expect( - newWrapper - .find('[data-test-subj="case-configure-action-bottom-bar-cancel-button"]') - .first() - .prop('isDisabled') - ).toBe(true); + // Close the edit flyout + wrapper.find('button[data-test-subj="euiFlyoutCloseButton"]').simulate('click'); + wrapper.update(); - expect( - newWrapper - .find('[data-test-subj="case-configure-action-bottom-bar-save-button"]') - .first() - .prop('isDisabled') - ).toBe(true); - }); + expect( + wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists() + ).toBeTruthy(); - test('it disables the buttons of action bar when saving configuration', () => { - useCaseConfigureMock.mockImplementation( - ({ setConnector, setClosureType, setCurrentConfiguration }) => { - useEffect(() => setConnector('456'), []); - useEffect(() => setClosureType('close-by-user'), []); - useEffect( - () => - setCurrentConfiguration({ - connectorId: '123', - closureType: 'close-by-user', - }), - [] - ); - return { ...useCaseConfigureResponse, persistLoading: true }; - } - ); - - const newWrapper = mount(, { wrappingComponent: TestProviders }); - - expect( - newWrapper - .find('[data-test-subj="case-configure-action-bottom-bar-cancel-button"]') - .first() - .prop('isDisabled') - ).toBe(true); + expect(wrapper.find(ConnectorEditFlyout).prop('editFlyoutVisible')).toBe(false); - expect( - newWrapper - .find('[data-test-subj="case-configure-action-bottom-bar-save-button"]') - .first() - .prop('isDisabled') - ).toBe(true); - }); + expect( + wrapper + .find('[data-test-subj="case-configure-action-bottom-bar-total-changes"]') + .first() + .text() + ).toBe('1 unsaved changes'); + }); - test('it shows the loading spinner when saving configuration', () => { - useCaseConfigureMock.mockImplementation( - ({ setConnector, setClosureType, setCurrentConfiguration }) => { - useEffect(() => setConnector('456'), []); - useEffect(() => setClosureType('close-by-user'), []); - useEffect( - () => - setCurrentConfiguration({ - connectorId: '123', - closureType: 'close-by-user', - }), - [] - ); - return { ...useCaseConfigureResponse, persistLoading: true }; - } - ); - - const newWrapper = mount(, { wrappingComponent: TestProviders }); - - expect( - newWrapper - .find('[data-test-subj="case-configure-action-bottom-bar-cancel-button"]') - .first() - .prop('isLoading') - ).toBe(true); + test('it shows the action bar when the connector is changed', () => { + useCaseConfigureMock + .mockImplementationOnce(() => ({ + ...useCaseConfigureResponse, + mapping, + closureType: 'close-by-user', + connectorId: '123', + currentConfiguration: { connectorId: '123', closureType: 'close-by-user' }, + })) + .mockImplementation(() => ({ + ...useCaseConfigureResponse, + mapping, + closureType: 'close-by-user', + connectorId: '456', + currentConfiguration: { connectorId: '123', closureType: 'close-by-user' }, + })); + const wrapper = mount(, { wrappingComponent: TestProviders }); + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.update(); + wrapper.find('button[data-test-subj="dropdown-connector-456"]').simulate('click'); + wrapper.update(); + + expect( + wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists() + ).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="case-configure-action-bottom-bar-total-changes"]') + .first() + .text() + ).toBe('1 unsaved changes'); + }); + + test('it closes the action bar when pressing save', () => { + useCaseConfigureMock + .mockImplementationOnce(() => ({ + ...useCaseConfigureResponse, + mapping, + closureType: 'close-by-user', + connectorId: '456', + currentConfiguration: { connectorId: '456', closureType: 'close-by-user' }, + })) + .mockImplementation(() => ({ + ...useCaseConfigureResponse, + mapping, + closureType: 'close-by-pushing', + connectorId: '456', + currentConfiguration: { connectorId: '456', closureType: 'close-by-user' }, + })); + const wrapper = mount(, { wrappingComponent: TestProviders }); + wrapper.find('input[id="close-by-pushing"]').simulate('change'); + wrapper.update(); + + expect( + wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists() + ).toBeTruthy(); - expect( - newWrapper + wrapper .find('[data-test-subj="case-configure-action-bottom-bar-save-button"]') .first() - .prop('isLoading') - ).toBe(true); - }); + .simulate('click'); - test('it closes the action bar when pressing save', () => { - useCaseConfigureMock.mockImplementation( - ({ setConnector, setClosureType, setCurrentConfiguration }) => { - useEffect(() => setConnector('456'), []); - useEffect(() => setClosureType('close-by-user'), []); - useEffect( - () => - setCurrentConfiguration({ - connectorId: '123', - closureType: 'close-by-user', - }), - [] - ); - return useCaseConfigureResponse; - } - ); - - const newWrapper = mount(, { wrappingComponent: TestProviders }); - - newWrapper - .find('[data-test-subj="case-configure-action-bottom-bar-save-button"]') - .first() - .simulate('click'); - - newWrapper.update(); - - expect( - newWrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists() - ).toBeFalsy(); - }); - - test('it submits the configuration correctly', () => { - const persistCaseConfigure = jest.fn(); + wrapper.update(); - useCaseConfigureMock.mockImplementation( - ({ setConnector, setClosureType, setCurrentConfiguration }) => { - useEffect(() => setConnector('456'), []); - useEffect(() => setClosureType('close-by-user'), []); - useEffect( - () => - setCurrentConfiguration({ - connectorId: '123', - closureType: 'close-by-pushing', - }), - [] - ); - return { ...useCaseConfigureResponse, persistCaseConfigure }; - } - ); - - const newWrapper = mount(, { wrappingComponent: TestProviders }); - - newWrapper - .find('[data-test-subj="case-configure-action-bottom-bar-save-button"]') - .first() - .simulate('click'); - - newWrapper.update(); - - expect(persistCaseConfigure).toHaveBeenCalled(); - expect(persistCaseConfigure).toHaveBeenCalledWith({ - connectorId: '456', - connectorName: 'My Connector 2', - closureType: 'close-by-user', + expect( + wrapper.find('[data-test-subj="case-configure-action-bottom-bar"]').exists() + ).toBeFalsy(); }); }); - - test('it has the correct url on cancel button', () => { - const persistCaseConfigure = jest.fn(); - - useCaseConfigureMock.mockImplementation( - ({ setConnector, setClosureType, setCurrentConfiguration }) => { - useEffect(() => setConnector('456'), []); - useEffect(() => setClosureType('close-by-user'), []); - useEffect( - () => - setCurrentConfiguration({ - connectorId: '123', - closureType: 'close-by-user', - }), - [] - ); - return { ...useCaseConfigureResponse, persistCaseConfigure }; - } - ); - - const newWrapper = mount(, { wrappingComponent: TestProviders }); - - expect( - newWrapper - .find('[data-test-subj="case-configure-action-bottom-bar-cancel-button"]') - .first() - .prop('href') - ).toBe(`#/link-to/case${searchURL}`); - }); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx index 241dcef14a145..cdb8966d81342 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx @@ -4,14 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { - useReducer, - useCallback, - useEffect, - useState, - Dispatch, - SetStateAction, -} from 'react'; +import React, { useCallback, useEffect, useState, Dispatch, SetStateAction } from 'react'; import styled, { css } from 'styled-components'; import { @@ -38,17 +31,12 @@ import { import { ActionConnectorTableItem } from '../../../../../../../../plugins/triggers_actions_ui/public/types'; import { getCaseUrl } from '../../../../components/link_to'; import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search'; -import { - ClosureType, - CasesConfigurationMapping, - CCMapsCombinedActionAttributes, -} from '../../../../containers/case/configure/types'; +import { CCMapsCombinedActionAttributes } from '../../../../containers/case/configure/types'; import { Connectors } from '../configure_cases/connectors'; import { ClosureOptions } from '../configure_cases/closure_options'; import { Mapping } from '../configure_cases/mapping'; import { SectionWrapper } from '../wrappers'; import { navTabs } from '../../../../pages/home/home_navigations'; -import { configureCasesReducer, State, CurrentConfiguration } from './reducer'; import * as i18n from './translations'; const FormWrapper = styled.div` @@ -66,13 +54,6 @@ const FormWrapper = styled.div` `} `; -const initialState: State = { - connectorId: 'none', - closureType: 'close-by-user', - mapping: null, - currentConfiguration: { connectorId: 'none', closureType: 'close-by-user' }, -}; - const actionTypes: ActionType[] = [ { id: '.servicenow', @@ -102,44 +83,18 @@ const ConfigureCasesComponent: React.FC = ({ userC const [actionBarVisible, setActionBarVisible] = useState(false); const [totalConfigurationChanges, setTotalConfigurationChanges] = useState(0); - const [{ connectorId, closureType, mapping, currentConfiguration }, dispatch] = useReducer( - configureCasesReducer(), - initialState - ); - - const setCurrentConfiguration = useCallback((configuration: CurrentConfiguration) => { - dispatch({ - type: 'setCurrentConfiguration', - currentConfiguration: { ...configuration }, - }); - }, []); - - const setConnectorId = useCallback((newConnectorId: string) => { - dispatch({ - type: 'setConnectorId', - connectorId: newConnectorId, - }); - }, []); - - const setClosureType = useCallback((newClosureType: ClosureType) => { - dispatch({ - type: 'setClosureType', - closureType: newClosureType, - }); - }, []); - - const setMapping = useCallback((newMapping: CasesConfigurationMapping[]) => { - dispatch({ - type: 'setMapping', - mapping: newMapping, - }); - }, []); - - const { loading: loadingCaseConfigure, persistLoading, persistCaseConfigure } = useCaseConfigure({ - setConnector: setConnectorId, + const { + connectorId, + closureType, + mapping, + currentConfiguration, + loading: loadingCaseConfigure, + persistLoading, + persistCaseConfigure, + setConnector, setClosureType, - setCurrentConfiguration, - }); + setMapping, + } = useCaseConfigure(); const { loading: isLoadingConnectors, connectors, refetchConnectors } = useConnectors(); @@ -173,17 +128,15 @@ const ConfigureCasesComponent: React.FC = ({ userC }, []); const handleActionBar = useCallback(() => { - const unsavedChanges = difference(Object.values(currentConfiguration), [ + const currentConfigurationMinusName = { + connectorId: currentConfiguration.connectorId, + closureType: currentConfiguration.closureType, + }; + const unsavedChanges = difference(Object.values(currentConfigurationMinusName), [ connectorId, closureType, ]).length; - - if (unsavedChanges === 0) { - setActionBarVisible(false); - } else { - setActionBarVisible(true); - } - + setActionBarVisible(!(unsavedChanges === 0)); setTotalConfigurationChanges(unsavedChanges); }, [currentConfiguration, connectorId, closureType]); @@ -246,7 +199,13 @@ const ConfigureCasesComponent: React.FC = ({ userC useEffect(() => { handleActionBar(); - }, [connectors, connectorId, closureType, currentConfiguration]); + }, [ + connectors, + connectorId, + closureType, + currentConfiguration.connectorId, + currentConfiguration.closureType, + ]); return ( @@ -267,7 +226,7 @@ const ConfigureCasesComponent: React.FC = ({ userC connectors={connectors ?? []} disabled={persistLoading || isLoadingConnectors || !userCanCrud} isLoading={isLoadingConnectors} - onChangeConnector={setConnectorId} + onChangeConnector={setConnector} handleShowAddFlyout={onClickAddConnector} selectedConnector={connectorId} /> diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/reducer.test.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/reducer.test.ts deleted file mode 100644 index df958b75dc6b8..0000000000000 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/reducer.test.ts +++ /dev/null @@ -1,68 +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 { configureCasesReducer, Action, State } from './reducer'; -import { initialState, mapping } from './__mock__'; - -describe('Reducer', () => { - let reducer: (state: State, action: Action) => State; - - beforeAll(() => { - reducer = configureCasesReducer(); - }); - - test('it should set the correct configuration', () => { - const action: Action = { - type: 'setCurrentConfiguration', - currentConfiguration: { connectorId: '123', closureType: 'close-by-user' }, - }; - const state = reducer(initialState, action); - - expect(state).toEqual({ - ...state, - currentConfiguration: action.currentConfiguration, - }); - }); - - test('it should set the correct connector id', () => { - const action: Action = { - type: 'setConnectorId', - connectorId: '456', - }; - const state = reducer(initialState, action); - - expect(state).toEqual({ - ...state, - connectorId: action.connectorId, - }); - }); - - test('it should set the closure type', () => { - const action: Action = { - type: 'setClosureType', - closureType: 'close-by-pushing', - }; - const state = reducer(initialState, action); - - expect(state).toEqual({ - ...state, - closureType: action.closureType, - }); - }); - - test('it should set the mapping', () => { - const action: Action = { - type: 'setMapping', - mapping, - }; - const state = reducer(initialState, action); - - expect(state).toEqual({ - ...state, - mapping: action.mapping, - }); - }); -}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/reducer.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/reducer.ts deleted file mode 100644 index f6b9d38a76de3..0000000000000 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/reducer.ts +++ /dev/null @@ -1,71 +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 { - ClosureType, - CasesConfigurationMapping, -} from '../../../../containers/case/configure/types'; - -export interface State { - mapping: CasesConfigurationMapping[] | null; - connectorId: string; - closureType: ClosureType; - currentConfiguration: CurrentConfiguration; -} - -export interface CurrentConfiguration { - connectorId: State['connectorId']; - closureType: State['closureType']; -} - -export type Action = - | { - type: 'setCurrentConfiguration'; - currentConfiguration: CurrentConfiguration; - } - | { - type: 'setConnectorId'; - connectorId: string; - } - | { - type: 'setClosureType'; - closureType: ClosureType; - } - | { - type: 'setMapping'; - mapping: CasesConfigurationMapping[]; - }; - -export const configureCasesReducer = () => (state: State, action: Action) => { - switch (action.type) { - case 'setCurrentConfiguration': { - return { - ...state, - currentConfiguration: { ...action.currentConfiguration }, - }; - } - case 'setConnectorId': { - return { - ...state, - connectorId: action.connectorId, - }; - } - case 'setClosureType': { - return { - ...state, - closureType: action.closureType, - }; - } - case 'setMapping': { - return { - ...state, - mapping: action.mapping, - }; - } - default: - return state; - } -}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/use_push_to_service/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/use_push_to_service/index.tsx index 5092cba6872e3..6109fd05096b9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/use_push_to_service/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/use_push_to_service/index.tsx @@ -6,7 +6,7 @@ import { EuiButton, EuiLink, EuiToolTip } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import React, { useCallback, useState, useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useCaseConfigure } from '../../../../containers/case/configure/use_configure'; import { Case } from '../../../../containers/case/types'; @@ -27,11 +27,6 @@ export interface UsePushToService { userCanCrud: boolean; } -interface Connector { - connectorId: string; - connectorName: string; -} - export interface ReturnUsePushToService { pushButton: JSX.Element; pushCallouts: JSX.Element | null; @@ -45,40 +40,30 @@ export const usePushToService = ({ userCanCrud, }: UsePushToService): ReturnUsePushToService => { const urlSearch = useGetUrlSearch(navTabs.case); - const [connector, setConnector] = useState(null); const { isLoading, postPushToService } = usePostPushToService(); - const handleSetConnector = useCallback((connectorId: string, connectorName?: string) => { - setConnector({ connectorId, connectorName: connectorName ?? '' }); - }, []); - - const { loading: loadingCaseConfigure } = useCaseConfigure({ - setConnector: handleSetConnector, - }); + const { connectorId, connectorName, loading: loadingCaseConfigure } = useCaseConfigure(); const { isLoading: loadingLicense, actionLicense } = useGetActionLicense(); const handlePushToService = useCallback(() => { - if (connector != null) { + if (connectorId != null) { postPushToService({ caseId, - ...connector, + connectorId, + connectorName, updateCase, }); } - }, [caseId, connector, postPushToService, updateCase]); + }, [caseId, connectorId, connectorName, postPushToService, updateCase]); const errorsMsg = useMemo(() => { let errors: Array<{ title: string; description: JSX.Element }> = []; if (actionLicense != null && !actionLicense.enabledInLicense) { errors = [...errors, getLicenseError()]; } - if ( - (connector == null || (connector != null && connector.connectorId === 'none')) && - !loadingCaseConfigure && - !loadingLicense - ) { + if (connectorId === 'none' && !loadingCaseConfigure && !loadingLicense) { errors = [ ...errors, { @@ -117,7 +102,7 @@ export const usePushToService = ({ errors = [...errors, getKibanaConfigError()]; } return errors; - }, [actionLicense, caseStatus, connector, loadingCaseConfigure, loadingLicense, urlSearch]); + }, [actionLicense, caseStatus, connectorId, loadingCaseConfigure, loadingLicense, urlSearch]); const pushToServiceButton = useMemo( () => ( diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index d6c85606edc2c..decd170ca5dd6 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -143,7 +143,8 @@ This is the primary function for an action type. Whenever the action needs to ex | actionId | The action saved object id that the action type is executing for. | | config | The decrypted configuration given to an action. This comes from the action saved object that is partially or fully encrypted within the data store. If you would like to validate the config before being passed to the executor, define `validate.config` within the action type. | | params | Parameters for the execution. These will be given at execution time by either an alert or manually provided when calling the plugin provided execute function. | -| services.callCluster(path, opts) | Use this to do Elasticsearch queries on the cluster Kibana connects to. This function is the same as any other `callCluster` in Kibana.

**NOTE**: This currently authenticates as the Kibana internal user, but will change in a future PR. | +| services.callCluster(path, opts) | Use this to do Elasticsearch queries on the cluster Kibana connects to. This function is the same as any other `callCluster` in Kibana but runs in the context of the user who is calling the action when security is enabled.| +| services.getScopedCallCluster | This function scopes an instance of CallCluster by returning a `callCluster(path, opts)` function that runs in the context of the user who is calling the action when security is enabled. This must only be called with instances of CallCluster provided by core.| | services.savedObjectsClient | This is an instance of the saved objects client. This provides the ability to do CRUD on any saved objects within the same space the alert lives in.

The scope of the saved objects client is tied to the user in context calling the execute API or the API key provided to the execute plugin function (only when security isenabled). | | services.log(tags, [data], [timestamp]) | Use this to create server logs. (This is the same function as server.log) | diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts index 265ff267f222e..18b434e980eb9 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts @@ -9,13 +9,13 @@ jest.mock('./lib/send_email', () => ({ })); import { Logger } from '../../../../../src/core/server'; -import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; import { ActionType, ActionTypeExecutorOptions } from '../types'; import { actionsConfigMock } from '../actions_config.mock'; import { validateConfig, validateSecrets, validateParams } from '../lib'; import { createActionTypeRegistry } from './index.test'; import { sendEmail } from './lib/send_email'; +import { actionsMock } from '../mocks'; import { ActionParamsType, ActionTypeConfigType, @@ -26,13 +26,8 @@ import { const sendEmailMock = sendEmail as jest.Mock; const ACTION_TYPE_ID = '.email'; -const NO_OP_FN = () => {}; -const services = { - log: NO_OP_FN, - callCluster: async (path: string, opts: unknown) => {}, - savedObjectsClient: savedObjectsClientMock.create(), -}; +const services = actionsMock.createServices(); let actionType: ActionType; let mockedLogger: jest.Mocked; diff --git a/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts index ed57e44c3f0b3..be60f4c2f28af 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts @@ -10,18 +10,13 @@ jest.mock('./lib/send_email', () => ({ import { ActionType, ActionTypeExecutorOptions } from '../types'; import { validateConfig, validateParams } from '../lib'; -import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; import { createActionTypeRegistry } from './index.test'; import { ActionParamsType, ActionTypeConfigType } from './es_index'; +import { actionsMock } from '../mocks'; const ACTION_TYPE_ID = '.index'; -const NO_OP_FN = () => {}; -const services = { - log: NO_OP_FN, - callCluster: jest.fn(), - savedObjectsClient: savedObjectsClientMock.create(), -}; +const services = actionsMock.createServices(); let actionType: ActionType; @@ -196,9 +191,9 @@ describe('execute()', () => { await actionType.executor(executorOptions); const calls = services.callCluster.mock.calls; - const timeValue = calls[0][1].body[1].field_to_use_for_time; + const timeValue = calls[0][1]?.body[1].field_to_use_for_time; expect(timeValue).toBeInstanceOf(Date); - delete calls[0][1].body[1].field_to_use_for_time; + delete calls[0][1]?.body[1].field_to_use_for_time; expect(calls).toMatchInlineSnapshot(` Array [ Array [ diff --git a/x-pack/plugins/actions/server/builtin_action_types/es_index.ts b/x-pack/plugins/actions/server/builtin_action_types/es_index.ts index 32f5e23015700..899684367d52d 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/es_index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/es_index.ts @@ -72,7 +72,7 @@ async function executor( bulkBody.push(document); } - const bulkParams: unknown = { + const bulkParams = { index, body: bulkBody, refresh: config.refresh, diff --git a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts index e54c8179ae7b4..1bca7c18e4e1b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts @@ -11,20 +11,17 @@ jest.mock('./lib/post_pagerduty', () => ({ import { getActionType } from './pagerduty'; import { ActionType, Services, ActionTypeExecutorOptions } from '../types'; import { validateConfig, validateSecrets, validateParams } from '../lib'; -import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; import { postPagerduty } from './lib/post_pagerduty'; import { createActionTypeRegistry } from './index.test'; import { Logger } from '../../../../../src/core/server'; import { actionsConfigMock } from '../actions_config.mock'; +import { actionsMock } from '../mocks'; const postPagerdutyMock = postPagerduty as jest.Mock; const ACTION_TYPE_ID = '.pagerduty'; -const services: Services = { - callCluster: async (path: string, opts: unknown) => {}, - savedObjectsClient: savedObjectsClientMock.create(), -}; +const services: Services = actionsMock.createServices(); let actionType: ActionType; let mockedLogger: jest.Mocked; diff --git a/x-pack/plugins/actions/server/builtin_action_types/server_log.test.ts b/x-pack/plugins/actions/server/builtin_action_types/server_log.test.ts index 3ce01c59596f6..d5a9c0cc1ccd2 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/server_log.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/server_log.test.ts @@ -7,8 +7,8 @@ import { ActionType } from '../types'; import { validateParams } from '../lib'; import { Logger } from '../../../../../src/core/server'; -import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; import { createActionTypeRegistry } from './index.test'; +import { actionsMock } from '../mocks'; const ACTION_TYPE_ID = '.server-log'; @@ -90,10 +90,7 @@ describe('execute()', () => { const actionId = 'some-id'; await actionType.executor({ actionId, - services: { - callCluster: async (path: string, opts: unknown) => {}, - savedObjectsClient: savedObjectsClientMock.create(), - }, + services: actionsMock.createServices(), params: { message: 'message text here', level: 'info' }, config: {}, secrets: {}, diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts index 08b837cf8d0a5..a6c3ae88765ac 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts @@ -7,9 +7,9 @@ import { getActionType } from '.'; import { ActionType, Services, ActionTypeExecutorOptions } from '../../types'; import { validateConfig, validateSecrets, validateParams } from '../../lib'; -import { savedObjectsClientMock } from '../../../../../../src/core/server/mocks'; import { createActionTypeRegistry } from '../index.test'; import { actionsConfigMock } from '../../actions_config.mock'; +import { actionsMock } from '../../mocks'; import { ACTION_TYPE_ID } from './constants'; import * as i18n from './translations'; @@ -21,10 +21,7 @@ jest.mock('./action_handlers'); const handleIncidentMock = handleIncident as jest.Mock; -const services: Services = { - callCluster: async (path: string, opts: unknown) => {}, - savedObjectsClient: savedObjectsClientMock.create(), -}; +const services: Services = actionsMock.createServices(); let actionType: ActionType; diff --git a/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts b/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts index 22c4b63474fdc..cbcd4b2954518 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts @@ -10,17 +10,14 @@ import { ActionTypeExecutorOptions, ActionTypeExecutorResult, } from '../types'; -import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; import { validateParams, validateSecrets } from '../lib'; import { getActionType } from './slack'; import { actionsConfigMock } from '../actions_config.mock'; +import { actionsMock } from '../mocks'; const ACTION_TYPE_ID = '.slack'; -const services: Services = { - callCluster: async (path: string, opts: unknown) => {}, - savedObjectsClient: savedObjectsClientMock.create(), -}; +const services: Services = actionsMock.createServices(); let actionType: ActionType; diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts index 1beaf92f3f48b..d28856954cca5 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts @@ -11,20 +11,17 @@ jest.mock('axios', () => ({ import { getActionType } from './webhook'; import { ActionType, Services } from '../types'; import { validateConfig, validateSecrets, validateParams } from '../lib'; -import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; import { actionsConfigMock } from '../actions_config.mock'; import { createActionTypeRegistry } from './index.test'; import { Logger } from '../../../../../src/core/server'; +import { actionsMock } from '../mocks'; import axios from 'axios'; const axiosRequestMock = axios.request as jest.Mock; const ACTION_TYPE_ID = '.webhook'; -const services: Services = { - callCluster: async (path: string, opts: unknown) => {}, - savedObjectsClient: savedObjectsClientMock.create(), -}; +const services: Services = actionsMock.createServices(); let actionType: ActionType; let mockedLogger: jest.Mocked; diff --git a/x-pack/plugins/actions/server/lib/action_executor.test.ts b/x-pack/plugins/actions/server/lib/action_executor.test.ts index d6719dc08225e..4594fc1ddf6d9 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.test.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.test.ts @@ -9,21 +9,15 @@ import { schema } from '@kbn/config-schema'; import { ActionExecutor } from './action_executor'; import { actionTypeRegistryMock } from '../action_type_registry.mock'; import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks'; -import { savedObjectsClientMock, loggingServiceMock } from '../../../../../src/core/server/mocks'; +import { loggingServiceMock } from '../../../../../src/core/server/mocks'; import { eventLoggerMock } from '../../../event_log/server/mocks'; import { spacesServiceMock } from '../../../spaces/server/spaces_service/spaces_service.mock'; import { ActionType } from '../types'; +import { actionsMock } from '../mocks'; const actionExecutor = new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false }); -const savedObjectsClient = savedObjectsClientMock.create(); - -function getServices() { - return { - savedObjectsClient, - log: jest.fn(), - callCluster: jest.fn(), - }; -} +const services = actionsMock.createServices(); +const savedObjectsClient = services.savedObjectsClient; const encryptedSavedObjectsPlugin = encryptedSavedObjectsMock.createStart(); const actionTypeRegistry = actionTypeRegistryMock.create(); @@ -39,7 +33,7 @@ const spacesMock = spacesServiceMock.createSetupContract(); actionExecutor.initialize({ logger: loggingServiceMock.create().get(), spaces: spacesMock, - getServices, + getServices: () => services, actionTypeRegistry, encryptedSavedObjectsPlugin, eventLogger: eventLoggerMock.create(), @@ -273,7 +267,7 @@ test('throws an error when passing isESOUsingEphemeralEncryptionKey with value o customActionExecutor.initialize({ logger: loggingServiceMock.create().get(), spaces: spacesMock, - getServices, + getServices: () => services, actionTypeRegistry, encryptedSavedObjectsPlugin, eventLogger: eventLoggerMock.create(), diff --git a/x-pack/plugins/actions/server/mocks.ts b/x-pack/plugins/actions/server/mocks.ts index 95c8b094dfd70..4160ace50f491 100644 --- a/x-pack/plugins/actions/server/mocks.ts +++ b/x-pack/plugins/actions/server/mocks.ts @@ -6,6 +6,11 @@ import { actionsClientMock } from './actions_client.mock'; import { PluginSetupContract, PluginStartContract } from './plugin'; +import { Services } from './types'; +import { + elasticsearchServiceMock, + savedObjectsClientMock, +} from '../../../../src/core/server/mocks'; export { actionsClientMock }; @@ -27,7 +32,19 @@ const createStartMock = () => { return mock; }; +const createServicesMock = () => { + const mock: jest.Mocked; + }> = { + callCluster: elasticsearchServiceMock.createScopedClusterClient().callAsCurrentUser, + getScopedCallCluster: jest.fn(), + savedObjectsClient: savedObjectsClientMock.create(), + }; + return mock; +}; + export const actionsMock = { + createServices: createServicesMock, createSetup: createSetupMock, createStart: createStartMock, }; diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 4c2c8d214f976..10e4d64584340 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -18,6 +18,7 @@ import { IContextProvider, SavedObjectsServiceStart, ElasticsearchServiceStart, + IClusterClient, } from '../../../../src/core/server'; import { @@ -302,6 +303,9 @@ export class ActionsPlugin implements Plugin, Plugi return request => ({ callCluster: elasticsearch.legacy.client.asScoped(request).callAsCurrentUser, savedObjectsClient: savedObjects.getScopedClient(request), + getScopedCallCluster(clusterClient: IClusterClient) { + return clusterClient.asScoped(request).callAsCurrentUser; + }, }); } diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index 9dcfdb81f5ebb..093d22c2c1a71 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -4,15 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - SavedObjectsClientContract, - SavedObjectAttributes, - KibanaRequest, -} from '../../../../src/core/server'; import { ActionTypeRegistry } from './action_type_registry'; import { PluginSetupContract, PluginStartContract } from './plugin'; import { ActionsClient } from './actions_client'; import { LicenseType } from '../../licensing/common/types'; +import { + IClusterClient, + IScopedClusterClient, + KibanaRequest, + SavedObjectsClientContract, + SavedObjectAttributes, +} from '../../../../src/core/server'; export type WithoutQueryAndParams = Pick>; export type GetServicesFunction = (request: KibanaRequest) => Services; @@ -21,8 +23,9 @@ export type GetBasePathFunction = (spaceId?: string) => string; export type SpaceIdToNamespaceFunction = (spaceId?: string) => string | undefined; export interface Services { - callCluster(path: string, opts: unknown): Promise; + callCluster: IScopedClusterClient['callAsCurrentUser']; savedObjectsClient: SavedObjectsClientContract; + getScopedCallCluster(clusterClient: IClusterClient): IScopedClusterClient['callAsCurrentUser']; } declare module 'src/core/server' { diff --git a/x-pack/plugins/alerting/README.md b/x-pack/plugins/alerting/README.md index 177e42de5a95b..62c2caed669af 100644 --- a/x-pack/plugins/alerting/README.md +++ b/x-pack/plugins/alerting/README.md @@ -101,6 +101,7 @@ This is the primary function for an alert type. Whenever the alert needs to exec |---|---| |services.callCluster(path, opts)|Use this to do Elasticsearch queries on the cluster Kibana connects to. This function is the same as any other `callCluster` in Kibana but in the context of the user who created the alert when security is enabled.| |services.savedObjectsClient|This is an instance of the saved objects client. This provides the ability to do CRUD on any saved objects within the same space the alert lives in.

The scope of the saved objects client is tied to the user who created the alert (only when security isenabled).| +|services.getScopedCallCluster|This function scopes an instance of CallCluster by returning a `callCluster(path, opts)` function that runs in the context of the user who created the alert when security is enabled. This must only be called with instances of CallCluster provided by core.| |services.log(tags, [data], [timestamp])|Use this to create server logs. (This is the same function as server.log)| |startedAt|The date and time the alert type started execution.| |previousStartedAt|The previous date and time the alert type started a successful execution.| diff --git a/x-pack/plugins/alerting/server/mocks.ts b/x-pack/plugins/alerting/server/mocks.ts index a9e224142a632..c94a7aba46cfa 100644 --- a/x-pack/plugins/alerting/server/mocks.ts +++ b/x-pack/plugins/alerting/server/mocks.ts @@ -6,8 +6,11 @@ import { alertsClientMock } from './alerts_client.mock'; import { PluginSetupContract, PluginStartContract } from './plugin'; -import { savedObjectsClientMock } from '../../../../src/core/server/mocks'; import { AlertInstance } from './alert_instance'; +import { + elasticsearchServiceMock, + savedObjectsClientMock, +} from '../../../../src/core/server/mocks'; export { alertsClientMock }; @@ -55,7 +58,8 @@ const createAlertServicesMock = () => { alertInstanceFactory: jest .fn, [string]>() .mockReturnValue(alertInstanceFactoryMock), - callCluster: jest.fn(), + callCluster: elasticsearchServiceMock.createScopedClusterClient().callAsCurrentUser, + getScopedCallCluster: jest.fn(), savedObjectsClient: savedObjectsClientMock.create(), }; }; diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index 35ebafce9dc67..c03d3506a051d 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -29,6 +29,7 @@ import { RequestHandler, SharedGlobalConfig, ElasticsearchServiceStart, + IClusterClient, } from '../../../../src/core/server'; import { @@ -270,6 +271,9 @@ export class AlertingPlugin { return request => ({ callCluster: elasticsearch.legacy.client.asScoped(request).callAsCurrentUser, savedObjectsClient: savedObjects.getScopedClient(request), + getScopedCallCluster(clusterClient: IClusterClient) { + return clusterClient.asScoped(request).callAsCurrentUser; + }, }); } diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index 0f600c7df7bf7..8b14199b7276a 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -11,9 +11,10 @@ import { ConcreteTaskInstance, TaskStatus } from '../../../../plugins/task_manag import { TaskRunnerContext } from './task_runner_factory'; import { TaskRunner } from './task_runner'; import { encryptedSavedObjectsMock } from '../../../../plugins/encrypted_saved_objects/server/mocks'; -import { savedObjectsClientMock, loggingServiceMock } from '../../../../../src/core/server/mocks'; +import { loggingServiceMock } from '../../../../../src/core/server/mocks'; import { PluginStartContract as ActionsPluginStart } from '../../../actions/server'; import { actionsMock } from '../../../actions/server/mocks'; +import { alertsMock } from '../mocks'; import { eventLoggerMock } from '../../../event_log/server/event_logger.mock'; import { IEventLogger } from '../../../event_log/server'; import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; @@ -52,13 +53,9 @@ describe('Task Runner', () => { afterAll(() => fakeTimer.restore()); - const savedObjectsClient = savedObjectsClientMock.create(); const encryptedSavedObjectsPlugin = encryptedSavedObjectsMock.createStart(); - const services = { - log: jest.fn(), - callCluster: jest.fn(), - savedObjectsClient, - }; + const services = alertsMock.createAlertServices(); + const savedObjectsClient = services.savedObjectsClient; const taskRunnerFactoryInitializerParams: jest.Mocked & { actionsPlugin: jest.Mocked; diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts index 1d220f97f127a..563664d3544ac 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts @@ -8,8 +8,9 @@ import sinon from 'sinon'; import { ConcreteTaskInstance, TaskStatus } from '../../../../plugins/task_manager/server'; import { TaskRunnerContext, TaskRunnerFactory } from './task_runner_factory'; import { encryptedSavedObjectsMock } from '../../../../plugins/encrypted_saved_objects/server/mocks'; -import { savedObjectsClientMock, loggingServiceMock } from '../../../../../src/core/server/mocks'; +import { loggingServiceMock } from '../../../../../src/core/server/mocks'; import { actionsMock } from '../../../actions/server/mocks'; +import { alertsMock } from '../mocks'; import { eventLoggerMock } from '../../../event_log/server/event_logger.mock'; const alertType = { @@ -48,13 +49,8 @@ describe('Task Runner Factory', () => { afterAll(() => fakeTimer.restore()); - const savedObjectsClient = savedObjectsClientMock.create(); const encryptedSavedObjectsPlugin = encryptedSavedObjectsMock.createStart(); - const services = { - log: jest.fn(), - callCluster: jest.fn(), - savedObjectsClient, - }; + const services = alertsMock.createAlertServices(); const taskRunnerFactoryInitializerParams: jest.Mocked = { getServices: jest.fn().mockReturnValue(services), diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index bc98cae65b4e6..b733b23dd71e6 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -7,14 +7,16 @@ import { AlertInstance } from './alert_instance'; import { AlertTypeRegistry as OrigAlertTypeRegistry } from './alert_type_registry'; import { PluginSetupContract, PluginStartContract } from './plugin'; +import { Alert, AlertActionParams, ActionGroup } from '../common'; +import { AlertsClient } from './alerts_client'; +export * from '../common'; import { + IClusterClient, + IScopedClusterClient, + KibanaRequest, SavedObjectAttributes, SavedObjectsClientContract, - KibanaRequest, } from '../../../../src/core/server'; -import { Alert, AlertActionParams, ActionGroup } from '../common'; -import { AlertsClient } from './alerts_client'; -export * from '../common'; // This will have to remain `any` until we can extend Alert Executors with generics // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -36,10 +38,9 @@ declare module 'src/core/server' { } export interface Services { - // This will have to remain `any` until we can extend Alert Services with generics - // eslint-disable-next-line @typescript-eslint/no-explicit-any - callCluster(path: string, opts: any): Promise; + callCluster: IScopedClusterClient['callAsCurrentUser']; savedObjectsClient: SavedObjectsClientContract; + getScopedCallCluster(clusterClient: IClusterClient): IScopedClusterClient['callAsCurrentUser']; } export interface AlertServices extends Services { diff --git a/x-pack/plugins/endpoint/common/generate_data.ts b/x-pack/plugins/endpoint/common/generate_data.ts index 1978d780f54f5..4f5a6eaeb0a5e 100644 --- a/x-pack/plugins/endpoint/common/generate_data.ts +++ b/x-pack/plugins/endpoint/common/generate_data.ts @@ -6,7 +6,16 @@ import uuid from 'uuid'; import seedrandom from 'seedrandom'; -import { AlertEvent, EndpointEvent, HostMetadata, OSFields, HostFields, PolicyData } from './types'; +import { + AlertEvent, + EndpointEvent, + HostFields, + HostMetadata, + OSFields, + PolicyData, + HostPolicyResponse, + HostPolicyResponseActionStatus, +} from './types'; import { factory as policyFactory } from './models/policy_config'; export type Event = AlertEvent | EndpointEvent; @@ -486,6 +495,112 @@ export class EndpointDocGenerator { }; } + /** + * Generates a Host Policy response message + */ + generatePolicyResponse(): HostPolicyResponse { + return { + '@timestamp': new Date().toISOString(), + elastic: { + agent: { + id: 'c2a9093e-e289-4c0a-aa44-8c32a414fa7a', + }, + }, + ecs: { + version: '1.0.0', + }, + event: { + created: '2015-01-01T12:10:30Z', + kind: 'policy_response', + }, + agent: { + version: '6.0.0-rc2', + id: '8a4f500d', + }, + endpoint: { + artifacts: { + 'global-manifest': { + version: '1.2.3', + sha256: 'abcdef', + }, + 'endpointpe-v4-windows': { + version: '1.2.3', + sha256: 'abcdef', + }, + 'user-whitelist-windows': { + version: '1.2.3', + sha256: 'abcdef', + }, + 'global-whitelist-windows': { + version: '1.2.3', + sha256: 'abcdef', + }, + }, + policy: { + applied: { + version: '1.0.0', + id: '17d4b81d-9940-4b64-9de5-3e03ef1fb5cf', + status: HostPolicyResponseActionStatus.success, + response: { + configurations: { + malware: { + status: HostPolicyResponseActionStatus.success, + concerned_actions: ['download_model', 'workflow', 'a_custom_future_action'], + }, + events: { + status: HostPolicyResponseActionStatus.success, + concerned_actions: ['ingest_events_config', 'workflow'], + }, + logging: { + status: HostPolicyResponseActionStatus.success, + concerned_actions: ['configure_elasticsearch_connection'], + }, + streaming: { + status: HostPolicyResponseActionStatus.success, + concerned_actions: [ + 'detect_file_open_events', + 'download_global_artifacts', + 'a_custom_future_action', + ], + }, + }, + actions: { + download_model: { + status: HostPolicyResponseActionStatus.success, + message: 'model downloaded', + }, + ingest_events_config: { + status: HostPolicyResponseActionStatus.success, + message: 'no action taken', + }, + workflow: { + status: HostPolicyResponseActionStatus.success, + message: 'the flow worked well', + }, + a_custom_future_action: { + status: HostPolicyResponseActionStatus.success, + message: 'future message', + }, + configure_elasticsearch_connection: { + status: HostPolicyResponseActionStatus.success, + message: 'some message', + }, + detect_file_open_events: { + status: HostPolicyResponseActionStatus.success, + message: 'some message', + }, + download_global_artifacts: { + status: HostPolicyResponseActionStatus.success, + message: 'some message', + }, + }, + }, + }, + }, + }, + }; + } + private randomN(n: number): number { return Math.floor(this.random() * n); } diff --git a/x-pack/plugins/endpoint/common/types.ts b/x-pack/plugins/endpoint/common/types.ts index 49f8ebbd580d8..4da4f9bc797ca 100644 --- a/x-pack/plugins/endpoint/common/types.ts +++ b/x-pack/plugins/endpoint/common/types.ts @@ -573,3 +573,103 @@ export type NewPolicyData = NewDatasource & { } ]; }; + +/** + * the possible status for actions, configurations and overall Policy Response + */ +export enum HostPolicyResponseActionStatus { + success = 'success', + failure = 'failure', + warning = 'warning', +} + +/** + * The details of a given action + */ +interface HostPolicyResponseActionDetails { + status: HostPolicyResponseActionStatus; + message: string; +} + +/** + * A known list of possible Endpoint actions + */ +interface HostPolicyResponseActions { + download_model: HostPolicyResponseActionDetails; + ingest_events_config: HostPolicyResponseActionDetails; + workflow: HostPolicyResponseActionDetails; + configure_elasticsearch_connection: HostPolicyResponseActionDetails; + configure_kernel: HostPolicyResponseActionDetails; + configure_logging: HostPolicyResponseActionDetails; + configure_malware: HostPolicyResponseActionDetails; + connect_kernel: HostPolicyResponseActionDetails; + detect_file_open_events: HostPolicyResponseActionDetails; + detect_file_write_events: HostPolicyResponseActionDetails; + detect_image_load_events: HostPolicyResponseActionDetails; + detect_process_events: HostPolicyResponseActionDetails; + download_global_artifacts: HostPolicyResponseActionDetails; + load_config: HostPolicyResponseActionDetails; + load_malware_model: HostPolicyResponseActionDetails; + read_elasticsearch_config: HostPolicyResponseActionDetails; + read_events_config: HostPolicyResponseActionDetails; + read_kernel_config: HostPolicyResponseActionDetails; + read_logging_config: HostPolicyResponseActionDetails; + read_malware_config: HostPolicyResponseActionDetails; + // The list of possible Actions will change rapidly, so the below entry will allow + // them without us defining them here statically + [key: string]: HostPolicyResponseActionDetails; +} + +interface HostPolicyResponseConfigurationStatus { + status: HostPolicyResponseActionStatus; + concerned_actions: Array; +} + +/** + * Information about the applying of a policy to a given host + */ +export interface HostPolicyResponse { + '@timestamp': string; + elastic: { + agent: { + id: string; + }; + }; + ecs: { + version: string; + }; + event: { + created: string; + kind: string; + }; + agent: { + version: string; + id: string; + }; + endpoint: { + artifacts: {}; + policy: { + applied: { + version: string; + id: string; + status: HostPolicyResponseActionStatus; + response: { + configurations: { + malware: HostPolicyResponseConfigurationStatus; + events: HostPolicyResponseConfigurationStatus; + logging: HostPolicyResponseConfigurationStatus; + streaming: HostPolicyResponseConfigurationStatus; + }; + actions: Partial; + }; + }; + }; + }; +} + +/** + * REST API response for retrieving a host's Policy Response status + */ +export interface GetHostPolicyResponse { + policy_response: HostPolicyResponse; +} diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/action.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/action.ts index 56a49df3bdab4..16a1f96c926b8 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/action.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/action.ts @@ -5,7 +5,7 @@ */ import { ServerApiError } from '../../types'; -import { HostResultList, HostInfo } from '../../../../../common/types'; +import { HostResultList, HostInfo, GetHostPolicyResponse } from '../../../../../common/types'; interface ServerReturnedHostList { type: 'serverReturnedHostList'; @@ -27,8 +27,14 @@ interface ServerFailedToReturnHostDetails { payload: ServerApiError; } +interface ServerReturnedHostPolicyResponse { + type: 'serverReturnedHostPolicyResponse'; + payload: GetHostPolicyResponse; +} + export type HostAction = | ServerReturnedHostList | ServerFailedToReturnHostList | ServerReturnedHostDetails - | ServerFailedToReturnHostDetails; + | ServerFailedToReturnHostDetails + | ServerReturnedHostPolicyResponse; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.ts index bb1cfc4dd10af..d1b9a2cde4b31 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.ts @@ -8,6 +8,7 @@ import { HostResultList } from '../../../../../common/types'; import { isOnHostPage, hasSelectedHost, uiQueryParams, listData } from './selectors'; import { HostState } from '../../types'; import { ImmutableMiddlewareFactory } from '../../types'; +import { HostPolicyResponse } from '../../../../../common/types'; export const hostMiddlewareFactory: ImmutableMiddlewareFactory = coreStart => { return ({ getState, dispatch }) => next => async action => { @@ -69,6 +70,21 @@ export const hostMiddlewareFactory: ImmutableMiddlewareFactory = core type: 'serverReturnedHostDetails', payload: response, }); + // FIXME: once we have the API implementation in place, we should call it parallel with the above api call and then dispatch this with the results of the second call + dispatch({ + type: 'serverReturnedHostPolicyResponse', + payload: { + policy_response: ({ + endpoint: { + policy: { + applied: { + status: 'success', + }, + }, + }, + } as unknown) as HostPolicyResponse, // Temporary until we get API + }, + }); } catch (error) { dispatch({ type: 'serverFailedToReturnHostDetails', diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/reducer.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/reducer.ts index adf18fa50c24f..eb74c40ff3687 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/reducer.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/reducer.ts @@ -20,6 +20,7 @@ const initialState = (): HostState => { details: undefined, detailsLoading: false, detailsError: undefined, + policyResponse: undefined, location: undefined, }; }; @@ -63,6 +64,11 @@ export const hostListReducer: ImmutableReducer = ( detailsError: action.payload, detailsLoading: false, }; + } else if (action.type === 'serverReturnedHostPolicyResponse') { + return { + ...state, + policyResponse: action.payload.policy_response, + }; } else if (action.type === 'userChangedUrl') { const newState: Immutable = { ...state, diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/selectors.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/selectors.ts index b0f949ebbe757..b0711baf9cdff 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/selectors.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/selectors.ts @@ -86,3 +86,13 @@ export const showView: (state: HostState) => 'policy_response' | 'details' = cre return searchParams.show === 'policy_response' ? 'policy_response' : 'details'; } ); + +/** + * Returns the Policy Response overall status + */ +export const policyResponseStatus: (state: Immutable) => string = createSelector( + state => state.policyResponse, + policyResponse => { + return (policyResponse && policyResponse?.endpoint?.policy?.applied?.status) || ''; + } +); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/types.ts b/x-pack/plugins/endpoint/public/applications/endpoint/types.ts index e5e600f6c6288..0598ce5f38efa 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/types.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/types.ts @@ -22,6 +22,7 @@ import { MalwareFields, UIPolicyConfig, PolicyData, + HostPolicyResponse, } from '../../../common/types'; import { EndpointPluginStartDependencies } from '../../plugin'; import { AppAction } from './store/action'; @@ -107,6 +108,8 @@ export interface HostState { detailsLoading: boolean; /** api error from retrieving host details */ detailsError?: ServerApiError; + /** Holds the Policy Response for the Host currently being displayed in the details */ + policyResponse?: HostPolicyResponse; /** current location info */ location?: Immutable; } diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/host_details.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/host_details.tsx index e4da39d50304a..7d948f54bd0bc 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/host_details.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/host_details.tsx @@ -16,12 +16,12 @@ import { import React, { memo, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { HostMetadata } from '../../../../../../common/types'; +import { HostMetadata, HostPolicyResponseActionStatus } from '../../../../../../common/types'; import { FormattedDateAndTime } from '../../formatted_date_time'; import { LinkToApp } from '../../components/link_to_app'; import { useHostSelector, useHostLogsUrl } from '../hooks'; import { urlFromQueryParams } from '../url_from_query_params'; -import { uiQueryParams } from '../../../store/hosts/selectors'; +import { policyResponseStatus, uiQueryParams } from '../../../store/hosts/selectors'; import { useNavigateByRouterEventHandler } from '../../hooks/use_navigate_by_router_event_handler'; const HostIds = styled(EuiListGroupItem)` @@ -31,9 +31,20 @@ const HostIds = styled(EuiListGroupItem)` } `; +const POLICY_STATUS_TO_HEALTH_COLOR = Object.freeze< + { [key in keyof typeof HostPolicyResponseActionStatus]: string } +>({ + success: 'success', + warning: 'warning', + failure: 'danger', +}); + export const HostDetails = memo(({ details }: { details: HostMetadata }) => { const { appId, appPath, url } = useHostLogsUrl(details.host.id); const queryParams = useHostSelector(uiQueryParams); + const policyStatus = useHostSelector( + policyResponseStatus + ) as keyof typeof POLICY_STATUS_TO_HEALTH_COLOR; const detailsResultsUpper = useMemo(() => { return [ { @@ -79,7 +90,10 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => { defaultMessage: 'Policy Status', }), description: ( - + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} { onClick={policyStatusClickHandler} > @@ -126,6 +141,7 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => { details.host.ip, policyResponseUri.search, policyStatusClickHandler, + policyStatus, ]); return ( diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.test.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.test.tsx index 11dbed716c527..499efb4f4b8ed 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.test.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.test.tsx @@ -14,9 +14,11 @@ import { mockHostResultList, } from '../../store/hosts/mock_host_result_list'; import { AppContextTestRender, createAppRootMockRenderer } from '../../mocks'; -import { HostInfo } from '../../../../../common/types'; +import { HostInfo, HostPolicyResponseActionStatus } from '../../../../../common/types'; +import { EndpointDocGenerator } from '../../../../../common/generate_data'; describe('when on the hosts page', () => { + const docGenerator = new EndpointDocGenerator(); let render: () => ReturnType; let history: AppContextTestRender['history']; let store: AppContextTestRender['store']; @@ -91,6 +93,19 @@ describe('when on the hosts page', () => { describe('when there is a selected host in the url', () => { let hostDetails: HostInfo; + const dispatchServerReturnedHostPolicyResponse = ( + overallStatus: HostPolicyResponseActionStatus = HostPolicyResponseActionStatus.success + ) => { + const policyResponse = docGenerator.generatePolicyResponse(); + policyResponse.endpoint.policy.applied.status = overallStatus; + store.dispatch({ + type: 'serverReturnedHostPolicyResponse', + payload: { + policy_response: policyResponse, + }, + }); + }; + beforeEach(() => { const { host_status, @@ -137,7 +152,6 @@ describe('when on the hosts page', () => { const renderResult = render(); const policyStatusLink = await renderResult.findByTestId('policyStatusValue'); expect(policyStatusLink).not.toBeNull(); - expect(policyStatusLink.textContent).toEqual('Successful'); expect(policyStatusLink.getAttribute('href')).toEqual( '?page_index=0&page_size=10&selected_host=1&show=policy_response' ); @@ -154,6 +168,58 @@ describe('when on the hosts page', () => { '?page_index=0&page_size=10&selected_host=1&show=policy_response' ); }); + it('should display Success overall policy status', async () => { + const renderResult = render(); + reactTestingLibrary.act(() => { + dispatchServerReturnedHostPolicyResponse(HostPolicyResponseActionStatus.success); + }); + const policyStatusLink = await renderResult.findByTestId('policyStatusValue'); + expect(policyStatusLink.textContent).toEqual('Success'); + + const policyStatusHealth = await renderResult.findByTestId('policyStatusHealth'); + expect( + policyStatusHealth.querySelector('[data-euiicon-type][color="success"]') + ).not.toBeNull(); + }); + it('should display Warning overall policy status', async () => { + const renderResult = render(); + reactTestingLibrary.act(() => { + dispatchServerReturnedHostPolicyResponse(HostPolicyResponseActionStatus.warning); + }); + const policyStatusLink = await renderResult.findByTestId('policyStatusValue'); + expect(policyStatusLink.textContent).toEqual('Warning'); + + const policyStatusHealth = await renderResult.findByTestId('policyStatusHealth'); + expect( + policyStatusHealth.querySelector('[data-euiicon-type][color="warning"]') + ).not.toBeNull(); + }); + it('should display Failed overall policy status', async () => { + const renderResult = render(); + reactTestingLibrary.act(() => { + dispatchServerReturnedHostPolicyResponse(HostPolicyResponseActionStatus.failure); + }); + const policyStatusLink = await renderResult.findByTestId('policyStatusValue'); + expect(policyStatusLink.textContent).toEqual('Failed'); + + const policyStatusHealth = await renderResult.findByTestId('policyStatusHealth'); + expect( + policyStatusHealth.querySelector('[data-euiicon-type][color="danger"]') + ).not.toBeNull(); + }); + it('should display Unknown overall policy status', async () => { + const renderResult = render(); + reactTestingLibrary.act(() => { + dispatchServerReturnedHostPolicyResponse('' as HostPolicyResponseActionStatus); + }); + const policyStatusLink = await renderResult.findByTestId('policyStatusValue'); + expect(policyStatusLink.textContent).toEqual('Unknown'); + + const policyStatusHealth = await renderResult.findByTestId('policyStatusHealth'); + expect( + policyStatusHealth.querySelector('[data-euiicon-type][color="subdued"]') + ).not.toBeNull(); + }); it('should include the link to logs', async () => { const renderResult = render(); const linkToLogs = await renderResult.findByTestId('hostDetailsLinkToLogs'); @@ -176,7 +242,7 @@ describe('when on the hosts page', () => { expect(coreStart.application.navigateToApp.mock.calls).toHaveLength(1); }); }); - describe('when showing host Policy Response', () => { + describe('when showing host Policy Response panel', () => { let renderResult: ReturnType; beforeEach(async () => { renderResult = render(); diff --git a/x-pack/plugins/infra/common/alerting/logs/types.ts b/x-pack/plugins/infra/common/alerting/logs/types.ts new file mode 100644 index 0000000000000..cbfffbfd8f940 --- /dev/null +++ b/x-pack/plugins/infra/common/alerting/logs/types.ts @@ -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 { i18n } from '@kbn/i18n'; + +export const LOG_DOCUMENT_COUNT_ALERT_TYPE_ID = 'logs.alert.document.count'; + +export enum Comparator { + GT = 'more than', + GT_OR_EQ = 'more than or equals', + LT = 'less than', + LT_OR_EQ = 'less than or equals', + EQ = 'equals', + NOT_EQ = 'does not equal', + MATCH = 'matches', + NOT_MATCH = 'does not match', + MATCH_PHRASE = 'matches phrase', + NOT_MATCH_PHRASE = 'does not match phrase', +} + +// Maps our comparators to i18n strings, some comparators have more specific wording +// depending on the field type the comparator is being used with. +export const ComparatorToi18nMap = { + [Comparator.GT]: i18n.translate('xpack.infra.logs.alerting.comparator.gt', { + defaultMessage: 'more than', + }), + [Comparator.GT_OR_EQ]: i18n.translate('xpack.infra.logs.alerting.comparator.gtOrEq', { + defaultMessage: 'more than or equals', + }), + [Comparator.LT]: i18n.translate('xpack.infra.logs.alerting.comparator.lt', { + defaultMessage: 'less than', + }), + [Comparator.LT_OR_EQ]: i18n.translate('xpack.infra.logs.alerting.comparator.ltOrEq', { + defaultMessage: 'less than or equals', + }), + [Comparator.EQ]: i18n.translate('xpack.infra.logs.alerting.comparator.eq', { + defaultMessage: 'is', + }), + [Comparator.NOT_EQ]: i18n.translate('xpack.infra.logs.alerting.comparator.notEq', { + defaultMessage: 'is not', + }), + [`${Comparator.EQ}:number`]: i18n.translate('xpack.infra.logs.alerting.comparator.eqNumber', { + defaultMessage: 'equals', + }), + [`${Comparator.NOT_EQ}:number`]: i18n.translate( + 'xpack.infra.logs.alerting.comparator.notEqNumber', + { + defaultMessage: 'does not equal', + } + ), + [Comparator.MATCH]: i18n.translate('xpack.infra.logs.alerting.comparator.match', { + defaultMessage: 'matches', + }), + [Comparator.NOT_MATCH]: i18n.translate('xpack.infra.logs.alerting.comparator.notMatch', { + defaultMessage: 'does not match', + }), + [Comparator.MATCH_PHRASE]: i18n.translate('xpack.infra.logs.alerting.comparator.matchPhrase', { + defaultMessage: 'matches phrase', + }), + [Comparator.NOT_MATCH_PHRASE]: i18n.translate( + 'xpack.infra.logs.alerting.comparator.notMatchPhrase', + { + defaultMessage: 'does not match phrase', + } + ), +}; + +export enum AlertStates { + OK, + ALERT, + NO_DATA, + ERROR, +} + +export interface DocumentCount { + comparator: Comparator; + value: number; +} + +export interface Criterion { + field: string; + comparator: Comparator; + value: string | number; +} + +export interface LogDocumentCountAlertParams { + count: DocumentCount; + criteria: Criterion[]; + timeUnit: 's' | 'm' | 'h' | 'd'; + timeSize: number; +} + +export type TimeUnit = 's' | 'm' | 'h' | 'd'; diff --git a/x-pack/plugins/infra/public/components/alerting/logs/alert_dropdown.tsx b/x-pack/plugins/infra/public/components/alerting/logs/alert_dropdown.tsx new file mode 100644 index 0000000000000..dd888639b6d07 --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/logs/alert_dropdown.tsx @@ -0,0 +1,67 @@ +/* + * 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, { useState, useCallback, useMemo } from 'react'; +import { EuiPopover, EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { AlertFlyout } from './alert_flyout'; +import { useLinkProps } from '../../../hooks/use_link_props'; + +export const AlertDropdown = () => { + const [popoverOpen, setPopoverOpen] = useState(false); + const [flyoutVisible, setFlyoutVisible] = useState(false); + const manageAlertsLinkProps = useLinkProps( + { + app: 'kibana', + hash: 'management/kibana/triggersActions/alerts', + }, + { + hrefOnly: true, + } + ); + + const closePopover = useCallback(() => { + setPopoverOpen(false); + }, [setPopoverOpen]); + + const openPopover = useCallback(() => { + setPopoverOpen(true); + }, [setPopoverOpen]); + + const menuItems = useMemo(() => { + return [ + setFlyoutVisible(true)}> + + , + + + , + ]; + }, [manageAlertsLinkProps]); + + return ( + <> + + + + } + isOpen={popoverOpen} + closePopover={closePopover} + > + + + + + ); +}; diff --git a/x-pack/plugins/infra/public/components/alerting/logs/alert_flyout.tsx b/x-pack/plugins/infra/public/components/alerting/logs/alert_flyout.tsx new file mode 100644 index 0000000000000..b18c2e5b8d69c --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/logs/alert_flyout.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 React, { useContext } from 'react'; +import { AlertsContextProvider, AlertAdd } from '../../../../../triggers_actions_ui/public'; +import { TriggerActionsContext } from '../../../utils/triggers_actions_context'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { LOG_DOCUMENT_COUNT_ALERT_TYPE_ID } from '../../../../common/alerting/logs/types'; + +interface Props { + visible?: boolean; + setVisible: React.Dispatch>; +} + +export const AlertFlyout = (props: Props) => { + const { triggersActionsUI } = useContext(TriggerActionsContext); + const { services } = useKibana(); + + return ( + <> + {triggersActionsUI && ( + + + + )} + + ); +}; diff --git a/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/criteria.tsx b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/criteria.tsx new file mode 100644 index 0000000000000..a9b45a117c281 --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/criteria.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 React from 'react'; +import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { IFieldType } from 'src/plugins/data/public'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { IErrorObject } from '../../../../../../triggers_actions_ui/public/types'; +import { Criterion } from './criterion'; +import { + LogDocumentCountAlertParams, + Criterion as CriterionType, +} from '../../../../../common/alerting/logs/types'; + +interface Props { + fields: IFieldType[]; + criteria?: LogDocumentCountAlertParams['criteria']; + updateCriterion: (idx: number, params: Partial) => void; + removeCriterion: (idx: number) => void; + errors: IErrorObject; +} + +export const Criteria: React.FC = ({ + fields, + criteria, + updateCriterion, + removeCriterion, + errors, +}) => { + if (!criteria) return null; + return ( + + + {criteria.map((criterion, idx) => { + return ( + 1} + errors={errors[idx.toString()] as IErrorObject} + /> + ); + })} + + + ); +}; diff --git a/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/criterion.tsx b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/criterion.tsx new file mode 100644 index 0000000000000..e8cafecd94db1 --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/criterion.tsx @@ -0,0 +1,269 @@ +/* + * 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, { useState, useMemo, useCallback } from 'react'; +import { + EuiPopoverTitle, + EuiFlexItem, + EuiFlexGroup, + EuiPopover, + EuiSelect, + EuiFieldNumber, + EuiExpression, + EuiFieldText, + EuiButtonIcon, + EuiFormRow, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { IFieldType } from 'src/plugins/data/public'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { IErrorObject } from '../../../../../../triggers_actions_ui/public/types'; +import { + Comparator, + Criterion as CriterionType, + ComparatorToi18nMap, +} from '../../../../../common/alerting/logs/types'; + +const firstCriterionFieldPrefix = i18n.translate( + 'xpack.infra.logs.alertFlyout.firstCriterionFieldPrefix', + { + defaultMessage: 'with', + } +); + +const successiveCriterionFieldPrefix = i18n.translate( + 'xpack.infra.logs.alertFlyout.successiveCriterionFieldPrefix', + { + defaultMessage: 'and', + } +); + +const criterionFieldTitle = i18n.translate('xpack.infra.logs.alertFlyout.criterionFieldTitle', { + defaultMessage: 'Field', +}); + +const criterionComparatorValueTitle = i18n.translate( + 'xpack.infra.logs.alertFlyout.criterionComparatorValueTitle', + { + defaultMessage: 'Comparison : Value', + } +); + +const getCompatibleComparatorsForField = (fieldInfo: IFieldType | undefined) => { + if (fieldInfo?.type === 'number') { + return [ + { value: Comparator.GT, text: ComparatorToi18nMap[Comparator.GT] }, + { value: Comparator.GT_OR_EQ, text: ComparatorToi18nMap[Comparator.GT_OR_EQ] }, + { value: Comparator.LT, text: ComparatorToi18nMap[Comparator.LT] }, + { value: Comparator.LT_OR_EQ, text: ComparatorToi18nMap[Comparator.LT_OR_EQ] }, + { value: Comparator.EQ, text: ComparatorToi18nMap[`${Comparator.EQ}:number`] }, + { value: Comparator.NOT_EQ, text: ComparatorToi18nMap[`${Comparator.NOT_EQ}:number`] }, + ]; + } else if (fieldInfo?.aggregatable) { + return [ + { value: Comparator.EQ, text: ComparatorToi18nMap[Comparator.EQ] }, + { value: Comparator.NOT_EQ, text: ComparatorToi18nMap[Comparator.NOT_EQ] }, + ]; + } else { + return [ + { value: Comparator.MATCH, text: ComparatorToi18nMap[Comparator.MATCH] }, + { value: Comparator.NOT_MATCH, text: ComparatorToi18nMap[Comparator.NOT_MATCH] }, + { value: Comparator.MATCH_PHRASE, text: ComparatorToi18nMap[Comparator.MATCH_PHRASE] }, + { + value: Comparator.NOT_MATCH_PHRASE, + text: ComparatorToi18nMap[Comparator.NOT_MATCH_PHRASE], + }, + ]; + } +}; + +const getFieldInfo = (fields: IFieldType[], fieldName: string): IFieldType | undefined => { + return fields.find(field => { + return field.name === fieldName; + }); +}; + +interface Props { + idx: number; + fields: IFieldType[]; + criterion: CriterionType; + updateCriterion: (idx: number, params: Partial) => void; + removeCriterion: (idx: number) => void; + canDelete: boolean; + errors: IErrorObject; +} + +export const Criterion: React.FC = ({ + idx, + fields, + criterion, + updateCriterion, + removeCriterion, + canDelete, + errors, +}) => { + const [isFieldPopoverOpen, setIsFieldPopoverOpen] = useState(false); + const [isComparatorPopoverOpen, setIsComparatorPopoverOpen] = useState(false); + + const fieldOptions = useMemo(() => { + return fields.map(field => { + return { value: field.name, text: field.name }; + }); + }, [fields]); + + const fieldInfo: IFieldType | undefined = useMemo(() => { + return getFieldInfo(fields, criterion.field); + }, [fields, criterion]); + + const compatibleComparatorOptions = useMemo(() => { + return getCompatibleComparatorsForField(fieldInfo); + }, [fieldInfo]); + + const handleFieldChange = useCallback( + e => { + const fieldName = e.target.value; + const nextFieldInfo = getFieldInfo(fields, fieldName); + // If the field information we're dealing with has changed, reset the comparator and value. + if ( + fieldInfo && + nextFieldInfo && + (fieldInfo.type !== nextFieldInfo.type || + fieldInfo.aggregatable !== nextFieldInfo.aggregatable) + ) { + const compatibleComparators = getCompatibleComparatorsForField(nextFieldInfo); + updateCriterion(idx, { + field: fieldName, + comparator: compatibleComparators[0].value, + value: undefined, + }); + } else { + updateCriterion(idx, { field: fieldName }); + } + }, + [fieldInfo, fields, idx, updateCriterion] + ); + + return ( + + + + + setIsFieldPopoverOpen(true)} + /> + } + isOpen={isFieldPopoverOpen} + closePopover={() => setIsFieldPopoverOpen(false)} + ownFocus + panelPaddingSize="s" + anchorPosition="downLeft" + > +
+ {criterionFieldTitle} + 0} error={errors.field}> + + +
+
+
+ + setIsComparatorPopoverOpen(true)} + /> + } + isOpen={isComparatorPopoverOpen} + closePopover={() => setIsComparatorPopoverOpen(false)} + ownFocus + panelPaddingSize="s" + anchorPosition="downLeft" + > +
+ {criterionComparatorValueTitle} + + + 0} error={errors.comparator}> + + updateCriterion(idx, { comparator: e.target.value as Comparator }) + } + options={compatibleComparatorOptions} + /> + + + + 0} error={errors.value}> + {fieldInfo?.type === 'number' ? ( + { + const number = parseInt(e.target.value, 10); + updateCriterion(idx, { value: number ? number : undefined }); + }} + /> + ) : ( + updateCriterion(idx, { value: e.target.value })} + /> + )} + + + +
+
+
+
+
+ {canDelete && ( + + removeCriterion(idx)} + /> + + )} +
+ ); +}; diff --git a/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/document_count.tsx b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/document_count.tsx new file mode 100644 index 0000000000000..308165ce08a9b --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/document_count.tsx @@ -0,0 +1,127 @@ +/* + * 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, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiPopoverTitle, + EuiFlexItem, + EuiFlexGroup, + EuiPopover, + EuiSelect, + EuiFieldNumber, + EuiExpression, + EuiFormRow, +} from '@elastic/eui'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { IErrorObject } from '../../../../../../triggers_actions_ui/public/types'; +import { + Comparator, + ComparatorToi18nMap, + LogDocumentCountAlertParams, +} from '../../../../../common/alerting/logs/types'; + +const documentCountPrefix = i18n.translate('xpack.infra.logs.alertFlyout.documentCountPrefix', { + defaultMessage: 'when', +}); + +const getComparatorOptions = (): Array<{ + value: Comparator; + text: string; +}> => { + return [ + { value: Comparator.LT, text: ComparatorToi18nMap[Comparator.LT] }, + { value: Comparator.LT_OR_EQ, text: ComparatorToi18nMap[Comparator.LT_OR_EQ] }, + { value: Comparator.GT, text: ComparatorToi18nMap[Comparator.GT] }, + { value: Comparator.GT_OR_EQ, text: ComparatorToi18nMap[Comparator.GT_OR_EQ] }, + ]; +}; + +interface Props { + comparator?: Comparator; + value?: number; + updateCount: (params: Partial) => void; + errors: IErrorObject; +} + +export const DocumentCount: React.FC = ({ comparator, value, updateCount, errors }) => { + const [isComparatorPopoverOpen, setComparatorPopoverOpenState] = useState(false); + const [isValuePopoverOpen, setIsValuePopoverOpen] = useState(false); + + const documentCountValue = i18n.translate('xpack.infra.logs.alertFlyout.documentCountValue', { + defaultMessage: '{value, plural, one {log entry} other {log entries}}', + values: { value }, + }); + + return ( + + + setComparatorPopoverOpenState(true)} + /> + } + isOpen={isComparatorPopoverOpen} + closePopover={() => setComparatorPopoverOpenState(false)} + ownFocus + panelPaddingSize="s" + anchorPosition="downLeft" + > +
+ {documentCountPrefix} + updateCount({ comparator: e.target.value as Comparator })} + options={getComparatorOptions()} + /> +
+
+
+ + + setIsValuePopoverOpen(true)} + color={errors.value.length === 0 ? 'secondary' : 'danger'} + /> + } + isOpen={isValuePopoverOpen} + closePopover={() => setIsValuePopoverOpen(false)} + ownFocus + panelPaddingSize="s" + anchorPosition="downLeft" + > +
+ {documentCountValue} + 0} error={errors.value}> + { + const number = parseInt(e.target.value, 10); + updateCount({ value: number ? number : undefined }); + }} + /> + +
+
+
+
+ ); +}; diff --git a/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx new file mode 100644 index 0000000000000..3aed0db53bf2c --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx @@ -0,0 +1,175 @@ +/* + * 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, useEffect, useState } from 'react'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + ForLastExpression, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../triggers_actions_ui/public/common'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { IErrorObject } from '../../../../../../triggers_actions_ui/public/types'; +import { useSource } from '../../../../containers/source'; +import { + LogDocumentCountAlertParams, + Comparator, + TimeUnit, +} from '../../../../../common/alerting/logs/types'; +import { DocumentCount } from './document_count'; +import { Criteria } from './criteria'; + +export interface ExpressionCriteria { + field?: string; + comparator?: Comparator; + value?: string | number; +} + +interface Props { + errors: IErrorObject; + alertParams: Partial; + setAlertParams(key: string, value: any): void; + setAlertProperty(key: string, value: any): void; +} + +const DEFAULT_CRITERIA = { field: 'log.level', comparator: Comparator.EQ, value: 'error' }; + +const DEFAULT_EXPRESSION = { + count: { + value: 75, + comparator: Comparator.GT, + }, + criteria: [DEFAULT_CRITERIA], + timeSize: 5, + timeUnit: 'm', +}; + +export const ExpressionEditor: React.FC = props => { + const { setAlertParams, alertParams, errors } = props; + const { createDerivedIndexPattern } = useSource({ sourceId: 'default' }); + const [timeSize, setTimeSize] = useState(1); + const [timeUnit, setTimeUnit] = useState('m'); + const [hasSetDefaults, setHasSetDefaults] = useState(false); + const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('logs'), [ + createDerivedIndexPattern, + ]); + + const supportedFields = useMemo(() => { + if (derivedIndexPattern?.fields) { + return derivedIndexPattern.fields.filter(field => { + return (field.type === 'string' || field.type === 'number') && field.searchable; + }); + } else { + return []; + } + }, [derivedIndexPattern]); + + // Set the default expression (disables exhaustive-deps as we only want to run this once on mount) + useEffect(() => { + for (const [key, value] of Object.entries(DEFAULT_EXPRESSION)) { + setAlertParams(key, value); + setHasSetDefaults(true); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const updateCount = useCallback( + countParams => { + const nextCountParams = { ...alertParams.count, ...countParams }; + setAlertParams('count', nextCountParams); + }, + [alertParams.count, setAlertParams] + ); + + const updateCriterion = useCallback( + (idx, criterionParams) => { + const nextCriteria = alertParams.criteria?.map((criterion, index) => { + return idx === index ? { ...criterion, ...criterionParams } : criterion; + }); + setAlertParams('criteria', nextCriteria ? nextCriteria : []); + }, + [alertParams, setAlertParams] + ); + + const updateTimeSize = useCallback( + (ts: number | undefined) => { + setTimeSize(ts || undefined); + setAlertParams('timeSize', ts); + }, + [setTimeSize, setAlertParams] + ); + + const updateTimeUnit = useCallback( + (tu: string) => { + setTimeUnit(tu as TimeUnit); + setAlertParams('timeUnit', tu); + }, + [setAlertParams] + ); + + const addCriterion = useCallback(() => { + const nextCriteria = alertParams?.criteria + ? [...alertParams.criteria, DEFAULT_CRITERIA] + : [DEFAULT_CRITERIA]; + setAlertParams('criteria', nextCriteria); + }, [alertParams, setAlertParams]); + + const removeCriterion = useCallback( + idx => { + const nextCriteria = alertParams?.criteria?.filter((criterion, index) => { + return index !== idx; + }); + setAlertParams('criteria', nextCriteria); + }, + [alertParams, setAlertParams] + ); + + // Wait until field info has loaded + if (supportedFields.length === 0) return null; + // Wait until the alert param defaults have been set + if (!hasSetDefaults) return null; + + return ( + <> + + + + + + +
+ + + +
+ + ); +}; diff --git a/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/index.tsx b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/index.tsx new file mode 100644 index 0000000000000..8b0fd5eb721b3 --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/index.tsx @@ -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 * from './editor'; diff --git a/x-pack/plugins/infra/public/components/alerting/logs/log_threshold_alert_type.ts b/x-pack/plugins/infra/public/components/alerting/logs/log_threshold_alert_type.ts new file mode 100644 index 0000000000000..18126ec583696 --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/logs/log_threshold_alert_type.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 { i18n } from '@kbn/i18n'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AlertTypeModel } from '../../../../../triggers_actions_ui/public/types'; +import { LOG_DOCUMENT_COUNT_ALERT_TYPE_ID } from '../../../../common/alerting/logs/types'; +import { ExpressionEditor } from './expression_editor'; +import { validateExpression } from './validation'; + +export function getAlertType(): AlertTypeModel { + return { + id: LOG_DOCUMENT_COUNT_ALERT_TYPE_ID, + name: i18n.translate('xpack.infra.logs.alertFlyout.alertName', { + defaultMessage: 'Log threshold', + }), + iconClass: 'bell', + alertParamsExpression: ExpressionEditor, + validate: validateExpression, + defaultActionMessage: i18n.translate( + 'xpack.infra.logs.alerting.threshold.defaultActionMessage', + { + defaultMessage: `\\{\\{context.matchingDocuments\\}\\} log entries have matched the following conditions: \\{\\{context.conditions\\}\\}`, + } + ), + }; +} diff --git a/x-pack/plugins/infra/public/components/alerting/logs/validation.ts b/x-pack/plugins/infra/public/components/alerting/logs/validation.ts new file mode 100644 index 0000000000000..c8c513f57a9d7 --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/logs/validation.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 { i18n } from '@kbn/i18n'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ValidationResult } from '../../../../../triggers_actions_ui/public/types'; +import { LogDocumentCountAlertParams } from '../../../../common/alerting/logs/types'; + +export function validateExpression({ + count, + criteria, + timeSize, + timeUnit, +}: Partial): ValidationResult { + const validationResult = { errors: {} }; + + // NOTE: In the case of components provided by the Alerting framework the error property names + // must match what they expect. + const errors: { + count: { + value: string[]; + }; + criteria: { + [id: string]: { + field: string[]; + comparator: string[]; + value: string[]; + }; + }; + timeWindowSize: string[]; + timeSizeUnit: string[]; + } = { + count: { + value: [], + }, + criteria: {}, + timeSizeUnit: [], + timeWindowSize: [], + }; + + validationResult.errors = errors; + + // Document count validation + if (typeof count?.value !== 'number') { + errors.count.value.push( + i18n.translate('xpack.infra.logs.alertFlyout.error.documentCountRequired', { + defaultMessage: 'Document count is Required.', + }) + ); + } + + // Time validation + if (!timeSize) { + errors.timeWindowSize.push( + i18n.translate('xpack.infra.logs.alertFlyout.error.timeSizeRequired', { + defaultMessage: 'Time size is Required.', + }) + ); + } + + if (criteria && criteria.length > 0) { + // Criteria validation + criteria.forEach((criterion, idx: number) => { + const id = idx.toString(); + + errors.criteria[id] = { + field: [], + comparator: [], + value: [], + }; + + if (!criterion.field) { + errors.criteria[id].field.push( + i18n.translate('xpack.infra.logs.alertFlyout.error.criterionFieldRequired', { + defaultMessage: 'Field is required.', + }) + ); + } + + if (!criterion.comparator) { + errors.criteria[id].comparator.push( + i18n.translate('xpack.infra.logs.alertFlyout.error.criterionComparatorRequired', { + defaultMessage: 'Comparator is required.', + }) + ); + } + + if (!criterion.value) { + errors.criteria[id].value.push( + i18n.translate('xpack.infra.logs.alertFlyout.error.criterionValueRequired', { + defaultMessage: 'Value is required.', + }) + ); + } + }); + } + + return validationResult; +} diff --git a/x-pack/plugins/infra/public/hooks/use_link_props.tsx b/x-pack/plugins/infra/public/hooks/use_link_props.tsx index 8c522bb7fa764..dec8eaae56f41 100644 --- a/x-pack/plugins/infra/public/hooks/use_link_props.tsx +++ b/x-pack/plugins/infra/public/hooks/use_link_props.tsx @@ -26,12 +26,20 @@ interface LinkProps { onClick?: (e: React.MouseEvent | React.MouseEvent) => void; } -export const useLinkProps = ({ app, pathname, hash, search }: LinkDescriptor): LinkProps => { +interface Options { + hrefOnly?: boolean; +} + +export const useLinkProps = ( + { app, pathname, hash, search }: LinkDescriptor, + options: Options = {} +): LinkProps => { validateParams({ app, pathname, hash, search }); const { prompt } = useNavigationWarningPrompt(); const prefixer = usePrefixPathWithBasepath(); const navigateToApp = useKibana().services.application?.navigateToApp; + const { hrefOnly } = options; const encodedSearch = useMemo(() => { return search ? encodeSearch(search) : undefined; @@ -86,7 +94,10 @@ export const useLinkProps = ({ app, pathname, hash, search }: LinkDescriptor): L return { href, - onClick, + // Sometimes it may not be desirable to have onClick call "navigateToApp". + // E.g. the management section of Kibana cannot be successfully deeplinked to via + // "navigateToApp". In those cases we can choose to defer to legacy behaviour. + onClick: hrefOnly ? undefined : onClick, }; }; diff --git a/x-pack/plugins/infra/public/pages/logs/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/page_content.tsx index ed6f06deeef64..dc210406275d8 100644 --- a/x-pack/plugins/infra/public/pages/logs/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/page_content.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { Route, Switch } from 'react-router-dom'; @@ -21,6 +22,7 @@ import { LogEntryCategoriesPage } from './log_entry_categories'; import { LogEntryRatePage } from './log_entry_rate'; import { LogsSettingsPage } from './settings'; import { StreamPage } from './stream'; +import { AlertDropdown } from '../../components/alerting/logs/alert_dropdown'; export const LogsPageContent: React.FunctionComponent = () => { const uiCapabilities = useKibana().services.application?.capabilities; @@ -65,13 +67,20 @@ export const LogsPageContent: React.FunctionComponent = () => { readOnlyBadge={!uiCapabilities?.logs?.save} /> - + + + + + + + + diff --git a/x-pack/plugins/infra/public/plugin.ts b/x-pack/plugins/infra/public/plugin.ts index 3b6647b9bfbbe..40366b2a54f24 100644 --- a/x-pack/plugins/infra/public/plugin.ts +++ b/x-pack/plugins/infra/public/plugin.ts @@ -21,7 +21,8 @@ import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/p import { DataEnhancedSetup, DataEnhancedStart } from '../../data_enhanced/public'; import { TriggersAndActionsUIPublicPluginSetup } from '../../../plugins/triggers_actions_ui/public'; -import { getAlertType } from './components/alerting/metrics/metric_threshold_alert_type'; +import { getAlertType as getMetricsAlertType } from './components/alerting/metrics/metric_threshold_alert_type'; +import { getAlertType as getLogsAlertType } from './components/alerting/logs/log_threshold_alert_type'; export type ClientSetup = void; export type ClientStart = void; @@ -52,7 +53,8 @@ export class Plugin setup(core: CoreSetup, pluginsSetup: ClientPluginsSetup) { registerFeatures(pluginsSetup.home); - pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(getAlertType()); + pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(getMetricsAlertType()); + pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(getLogsAlertType()); core.application.register({ id: 'logs', diff --git a/x-pack/plugins/infra/server/graphql/sources/resolvers.ts b/x-pack/plugins/infra/server/graphql/sources/resolvers.ts index 1fe1431392a38..f880eca933241 100644 --- a/x-pack/plugins/infra/server/graphql/sources/resolvers.ts +++ b/x-pack/plugins/infra/server/graphql/sources/resolvers.ts @@ -93,7 +93,10 @@ export const createSourcesResolvers = ( } => ({ Query: { async source(root, args, { req }) { - const requestedSourceConfiguration = await libs.sources.getSourceConfiguration(req, args.id); + const requestedSourceConfiguration = await libs.sources.getSourceConfiguration( + req.core.savedObjects.client, + args.id + ); return requestedSourceConfiguration; }, diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts new file mode 100644 index 0000000000000..cdec04ab81a8e --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts @@ -0,0 +1,250 @@ +/* + * 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'; +import { AlertExecutorOptions, AlertServices } from '../../../../../alerting/server'; +import { + AlertStates, + Comparator, + LogDocumentCountAlertParams, + Criterion, +} from '../../../../common/alerting/logs/types'; +import { InfraBackendLibs } from '../../infra_types'; +import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; +import { InfraSource } from '../../../../common/http_api/source_api'; + +const checkValueAgainstComparatorMap: { + [key: string]: (a: number, b: number) => boolean; +} = { + [Comparator.GT]: (a: number, b: number) => a > b, + [Comparator.GT_OR_EQ]: (a: number, b: number) => a >= b, + [Comparator.LT]: (a: number, b: number) => a < b, + [Comparator.LT_OR_EQ]: (a: number, b: number) => a <= b, +}; + +export const createLogThresholdExecutor = (alertUUID: string, libs: InfraBackendLibs) => + async function({ services, params }: AlertExecutorOptions) { + const { count, criteria } = params as LogDocumentCountAlertParams; + const { alertInstanceFactory, savedObjectsClient, callCluster } = services; + const { sources } = libs; + + const sourceConfiguration = await sources.getSourceConfiguration(savedObjectsClient, 'default'); + const indexPattern = sourceConfiguration.configuration.logAlias; + + const alertInstance = alertInstanceFactory(alertUUID); + + try { + const query = getESQuery( + params as LogDocumentCountAlertParams, + sourceConfiguration.configuration + ); + const result = await getResults(query, indexPattern, callCluster); + + if (checkValueAgainstComparatorMap[count.comparator](result.count, count.value)) { + alertInstance.scheduleActions(FIRED_ACTIONS.id, { + matchingDocuments: result.count, + conditions: createConditionsMessage(criteria), + }); + + alertInstance.replaceState({ + alertState: AlertStates.ALERT, + }); + } else { + alertInstance.replaceState({ + alertState: AlertStates.OK, + }); + } + } catch (e) { + alertInstance.replaceState({ + alertState: AlertStates.ERROR, + }); + + throw new Error(e); + } + }; + +const getESQuery = ( + params: LogDocumentCountAlertParams, + sourceConfiguration: InfraSource['configuration'] +): object => { + const { timeSize, timeUnit, criteria } = params; + const interval = `${timeSize}${timeUnit}`; + const intervalAsSeconds = getIntervalInSeconds(interval); + const to = Date.now(); + const from = to - intervalAsSeconds * 1000; + + const rangeFilters = [ + { + range: { + [sourceConfiguration.fields.timestamp]: { + gte: from, + lte: to, + format: 'epoch_millis', + }, + }, + }, + ]; + + const positiveComparators = getPositiveComparators(); + const negativeComparators = getNegativeComparators(); + const positiveCriteria = criteria.filter(criterion => + positiveComparators.includes(criterion.comparator) + ); + const negativeCriteria = criteria.filter(criterion => + negativeComparators.includes(criterion.comparator) + ); + // Positive assertions (things that "must" match) + const mustFilters = buildFiltersForCriteria(positiveCriteria); + // Negative assertions (things that "must not" match) + const mustNotFilters = buildFiltersForCriteria(negativeCriteria); + + const query = { + query: { + bool: { + filter: [...rangeFilters], + ...(mustFilters.length > 0 && { must: mustFilters }), + ...(mustNotFilters.length > 0 && { must_not: mustNotFilters }), + }, + }, + }; + + return query; +}; + +type SupportedESQueryTypes = 'term' | 'match' | 'match_phrase' | 'range'; +type Filter = { + [key in SupportedESQueryTypes]?: object; +}; + +const buildFiltersForCriteria = (criteria: LogDocumentCountAlertParams['criteria']) => { + let filters: Filter[] = []; + + criteria.forEach(criterion => { + const criterionQuery = buildCriterionQuery(criterion); + if (criterionQuery) { + filters = [...filters, criterionQuery]; + } + }); + return filters; +}; + +const buildCriterionQuery = (criterion: Criterion): Filter | undefined => { + const { field, value, comparator } = criterion; + + const queryType = getQueryMappingForComparator(comparator); + + switch (queryType) { + case 'term': + return { + term: { + [field]: { + value, + }, + }, + }; + break; + case 'match': { + return { + match: { + [field]: value, + }, + }; + } + case 'match_phrase': { + return { + match_phrase: { + [field]: value, + }, + }; + } + case 'range': { + const comparatorToRangePropertyMapping: { + [key: string]: string; + } = { + [Comparator.LT]: 'lt', + [Comparator.LT_OR_EQ]: 'lte', + [Comparator.GT]: 'gt', + [Comparator.GT_OR_EQ]: 'gte', + }; + + const rangeProperty = comparatorToRangePropertyMapping[comparator]; + + return { + range: { + [field]: { + [rangeProperty]: value, + }, + }, + }; + } + default: { + return undefined; + } + } +}; + +const getPositiveComparators = () => { + return [ + Comparator.GT, + Comparator.GT_OR_EQ, + Comparator.LT, + Comparator.LT_OR_EQ, + Comparator.EQ, + Comparator.MATCH, + Comparator.MATCH_PHRASE, + ]; +}; + +const getNegativeComparators = () => { + return [Comparator.NOT_EQ, Comparator.NOT_MATCH, Comparator.NOT_MATCH_PHRASE]; +}; + +const queryMappings: { + [key: string]: string; +} = { + [Comparator.GT]: 'range', + [Comparator.GT_OR_EQ]: 'range', + [Comparator.LT]: 'range', + [Comparator.LT_OR_EQ]: 'range', + [Comparator.EQ]: 'term', + [Comparator.MATCH]: 'match', + [Comparator.MATCH_PHRASE]: 'match_phrase', + [Comparator.NOT_EQ]: 'term', + [Comparator.NOT_MATCH]: 'match', + [Comparator.NOT_MATCH_PHRASE]: 'match_phrase', +}; + +const getQueryMappingForComparator = (comparator: Comparator) => { + return queryMappings[comparator]; +}; + +const getResults = async ( + query: object, + index: string, + callCluster: AlertServices['callCluster'] +) => { + return await callCluster('count', { + body: query, + index, + }); +}; + +const createConditionsMessage = (criteria: LogDocumentCountAlertParams['criteria']) => { + const parts = criteria.map((criterion, index) => { + const { field, comparator, value } = criterion; + return `${index === 0 ? '' : 'and'} ${field} ${comparator} ${value}`; + }); + return parts.join(' '); +}; + +// When the Alerting plugin implements support for multiple action groups, add additional +// action groups here to send different messages, e.g. a recovery notification +export const FIRED_ACTIONS = { + id: 'logs.threshold.fired', + name: i18n.translate('xpack.infra.logs.alerting.threshold.fired', { + defaultMessage: 'Fired', + }), +}; diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts new file mode 100644 index 0000000000000..04207a4233dfd --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts @@ -0,0 +1,90 @@ +/* + * 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 uuid from 'uuid'; +import { i18n } from '@kbn/i18n'; +import { schema } from '@kbn/config-schema'; +import { PluginSetupContract } from '../../../../../alerting/server'; +import { createLogThresholdExecutor, FIRED_ACTIONS } from './log_threshold_executor'; +import { + LOG_DOCUMENT_COUNT_ALERT_TYPE_ID, + Comparator, +} from '../../../../common/alerting/logs/types'; +import { InfraBackendLibs } from '../../infra_types'; + +const documentCountActionVariableDescription = i18n.translate( + 'xpack.infra.logs.alerting.threshold.documentCountActionVariableDescription', + { + defaultMessage: 'The number of log entries that matched the conditions provided', + } +); + +const conditionsActionVariableDescription = i18n.translate( + 'xpack.infra.logs.alerting.threshold.conditionsActionVariableDescription', + { + defaultMessage: 'The conditions that log entries needed to fulfill', + } +); + +const countSchema = schema.object({ + value: schema.number(), + comparator: schema.oneOf([ + schema.literal(Comparator.GT), + schema.literal(Comparator.LT), + schema.literal(Comparator.GT_OR_EQ), + schema.literal(Comparator.LT_OR_EQ), + schema.literal(Comparator.EQ), + ]), +}); + +const criteriaSchema = schema.object({ + field: schema.string(), + comparator: schema.oneOf([ + schema.literal(Comparator.GT), + schema.literal(Comparator.LT), + schema.literal(Comparator.GT_OR_EQ), + schema.literal(Comparator.LT_OR_EQ), + schema.literal(Comparator.EQ), + schema.literal(Comparator.NOT_EQ), + schema.literal(Comparator.MATCH), + schema.literal(Comparator.NOT_MATCH), + ]), + value: schema.oneOf([schema.number(), schema.string()]), +}); + +export async function registerLogThresholdAlertType( + alertingPlugin: PluginSetupContract, + libs: InfraBackendLibs +) { + if (!alertingPlugin) { + throw new Error( + 'Cannot register log threshold alert type. Both the actions and alerting plugins need to be enabled.' + ); + } + + const alertUUID = uuid.v4(); + + alertingPlugin.registerType({ + id: LOG_DOCUMENT_COUNT_ALERT_TYPE_ID, + name: 'Log threshold', + validate: { + params: schema.object({ + count: countSchema, + criteria: schema.arrayOf(criteriaSchema), + timeUnit: schema.string(), + timeSize: schema.number(), + }), + }, + defaultActionGroupId: FIRED_ACTIONS.id, + actionGroups: [FIRED_ACTIONS], + executor: createLogThresholdExecutor(alertUUID, libs), + actionVariables: { + context: [ + { name: 'matchingDocuments', description: documentCountActionVariableDescription }, + { name: 'conditions', description: conditionsActionVariableDescription }, + ], + }, + }); +} diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts index a52659dae01f1..24b6ba2ec378b 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts @@ -20,7 +20,7 @@ const executor = createMetricThresholdExecutor('test') as (opts: { }) => Promise; const services: AlertServicesMock = alertsMock.createAlertServices(); -services.callCluster.mockImplementation((_: string, { body, index }: any) => { +services.callCluster.mockImplementation(async (_: string, { body, index }: any) => { if (index === 'alternatebeat-*') return mocks.changedSourceIdResponse; const metric = body.query.bool.filter[1]?.exists.field; if (body.aggs.groupings) { diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts index b697af4fa4c3b..3415ae9873bfb 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { PluginSetupContract } from '../../../../../alerting/server'; import { METRIC_EXPLORER_AGGREGATIONS } from '../../../../common/http_api/metrics_explorer'; import { createMetricThresholdExecutor, FIRED_ACTIONS } from './metric_threshold_executor'; +import { InfraBackendLibs } from '../../infra_types'; import { METRIC_THRESHOLD_ALERT_TYPE_ID, Comparator } from './types'; const oneOfLiterals = (arrayOfLiterals: Readonly) => @@ -17,7 +18,10 @@ const oneOfLiterals = (arrayOfLiterals: Readonly) => arrayOfLiterals.includes(value) ? undefined : `must be one of ${arrayOfLiterals.join(' | ')}`, }); -export async function registerMetricThresholdAlertType(alertingPlugin: PluginSetupContract) { +export async function registerMetricThresholdAlertType( + alertingPlugin: PluginSetupContract, + libs: InfraBackendLibs +) { if (!alertingPlugin) { throw new Error( 'Cannot register metric threshold alert type. Both the actions and alerting plugins need to be enabled.' diff --git a/x-pack/plugins/infra/server/lib/alerting/register_alert_types.ts b/x-pack/plugins/infra/server/lib/alerting/register_alert_types.ts index 6ec6f31256b78..9760873ff7478 100644 --- a/x-pack/plugins/infra/server/lib/alerting/register_alert_types.ts +++ b/x-pack/plugins/infra/server/lib/alerting/register_alert_types.ts @@ -6,13 +6,15 @@ import { PluginSetupContract } from '../../../../alerting/server'; import { registerMetricThresholdAlertType } from './metric_threshold/register_metric_threshold_alert_type'; +import { registerLogThresholdAlertType } from './log_threshold/register_log_threshold_alert_type'; +import { InfraBackendLibs } from '../infra_types'; -const registerAlertTypes = (alertingPlugin: PluginSetupContract) => { +const registerAlertTypes = (alertingPlugin: PluginSetupContract, libs: InfraBackendLibs) => { if (alertingPlugin) { - const registerFns = [registerMetricThresholdAlertType]; + const registerFns = [registerMetricThresholdAlertType, registerLogThresholdAlertType]; registerFns.forEach(fn => { - fn(alertingPlugin); + fn(alertingPlugin, libs); }); } }; diff --git a/x-pack/plugins/infra/server/lib/domains/fields_domain.ts b/x-pack/plugins/infra/server/lib/domains/fields_domain.ts index d2e151ca2c3f5..b6837e5b769a6 100644 --- a/x-pack/plugins/infra/server/lib/domains/fields_domain.ts +++ b/x-pack/plugins/infra/server/lib/domains/fields_domain.ts @@ -21,7 +21,7 @@ export class InfraFieldsDomain { indexType: InfraIndexType ): Promise { const { configuration } = await this.libs.sources.getSourceConfiguration( - requestContext, + requestContext.core.savedObjects.client, sourceId ); const includeMetricIndices = [InfraIndexType.ANY, InfraIndexType.METRICS].includes(indexType); diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts index 1e84a8c016c87..07bc965dda77a 100644 --- a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts +++ b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts @@ -113,7 +113,7 @@ export class InfraLogEntriesDomain { params: LogEntriesParams ): Promise { const { configuration } = await this.libs.sources.getSourceConfiguration( - requestContext, + requestContext.core.savedObjects.client, sourceId ); @@ -172,7 +172,7 @@ export class InfraLogEntriesDomain { filterQuery?: LogEntryQuery ): Promise { const { configuration } = await this.libs.sources.getSourceConfiguration( - requestContext, + requestContext.core.savedObjects.client, sourceId ); const dateRangeBuckets = await this.adapter.getContainedLogSummaryBuckets( @@ -196,7 +196,7 @@ export class InfraLogEntriesDomain { filterQuery?: LogEntryQuery ): Promise { const { configuration } = await this.libs.sources.getSourceConfiguration( - requestContext, + requestContext.core.savedObjects.client, sourceId ); const messageFormattingRules = compileFormattingRules( diff --git a/x-pack/plugins/infra/server/lib/source_status.ts b/x-pack/plugins/infra/server/lib/source_status.ts index 1f0845b6b223f..9bb953845e5a1 100644 --- a/x-pack/plugins/infra/server/lib/source_status.ts +++ b/x-pack/plugins/infra/server/lib/source_status.ts @@ -18,7 +18,7 @@ export class InfraSourceStatus { sourceId: string ): Promise { const sourceConfiguration = await this.libs.sources.getSourceConfiguration( - requestContext, + requestContext.core.savedObjects.client, sourceId ); const indexNames = await this.adapter.getIndexNames( @@ -32,7 +32,7 @@ export class InfraSourceStatus { sourceId: string ): Promise { const sourceConfiguration = await this.libs.sources.getSourceConfiguration( - requestContext, + requestContext.core.savedObjects.client, sourceId ); const indexNames = await this.adapter.getIndexNames( @@ -46,7 +46,7 @@ export class InfraSourceStatus { sourceId: string ): Promise { const sourceConfiguration = await this.libs.sources.getSourceConfiguration( - requestContext, + requestContext.core.savedObjects.client, sourceId ); const hasAlias = await this.adapter.hasAlias( @@ -60,7 +60,7 @@ export class InfraSourceStatus { sourceId: string ): Promise { const sourceConfiguration = await this.libs.sources.getSourceConfiguration( - requestContext, + requestContext.core.savedObjects.client, sourceId ); const hasAlias = await this.adapter.hasAlias( @@ -74,7 +74,7 @@ export class InfraSourceStatus { sourceId: string ): Promise { const sourceConfiguration = await this.libs.sources.getSourceConfiguration( - requestContext, + requestContext.core.savedObjects.client, sourceId ); const hasIndices = await this.adapter.hasIndices( @@ -88,7 +88,7 @@ export class InfraSourceStatus { sourceId: string ): Promise { const sourceConfiguration = await this.libs.sources.getSourceConfiguration( - requestContext, + requestContext.core.savedObjects.client, sourceId ); const hasIndices = await this.adapter.hasIndices( diff --git a/x-pack/plugins/infra/server/lib/sources/sources.test.ts b/x-pack/plugins/infra/server/lib/sources/sources.test.ts index 4a83ca730ff83..57efb0f676b2f 100644 --- a/x-pack/plugins/infra/server/lib/sources/sources.test.ts +++ b/x-pack/plugins/infra/server/lib/sources/sources.test.ts @@ -29,7 +29,9 @@ describe('the InfraSources lib', () => { }, }); - expect(await sourcesLib.getSourceConfiguration(request, 'TEST_ID')).toMatchObject({ + expect( + await sourcesLib.getSourceConfiguration(request.core.savedObjects.client, 'TEST_ID') + ).toMatchObject({ id: 'TEST_ID', version: 'foo', updatedAt: 946684800000, @@ -74,7 +76,9 @@ describe('the InfraSources lib', () => { }, }); - expect(await sourcesLib.getSourceConfiguration(request, 'TEST_ID')).toMatchObject({ + expect( + await sourcesLib.getSourceConfiguration(request.core.savedObjects.client, 'TEST_ID') + ).toMatchObject({ id: 'TEST_ID', version: 'foo', updatedAt: 946684800000, @@ -104,7 +108,9 @@ describe('the InfraSources lib', () => { attributes: {}, }); - expect(await sourcesLib.getSourceConfiguration(request, 'TEST_ID')).toMatchObject({ + expect( + await sourcesLib.getSourceConfiguration(request.core.savedObjects.client, 'TEST_ID') + ).toMatchObject({ id: 'TEST_ID', version: 'foo', updatedAt: 946684800000, diff --git a/x-pack/plugins/infra/server/lib/sources/sources.ts b/x-pack/plugins/infra/server/lib/sources/sources.ts index 99e062aa49ccf..0368c7bfd6db8 100644 --- a/x-pack/plugins/infra/server/lib/sources/sources.ts +++ b/x-pack/plugins/infra/server/lib/sources/sources.ts @@ -9,7 +9,7 @@ import { failure } from 'io-ts/lib/PathReporter'; import { identity, constant } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; import { map, fold } from 'fp-ts/lib/Either'; -import { RequestHandlerContext } from 'src/core/server'; +import { RequestHandlerContext, SavedObjectsClientContract } from 'src/core/server'; import { defaultSourceConfiguration } from './defaults'; import { NotFoundError } from './errors'; import { infraSourceConfigurationSavedObjectType } from './saved_object_mappings'; @@ -37,7 +37,7 @@ export class InfraSources { } public async getSourceConfiguration( - requestContext: RequestHandlerContext, + savedObjectsClient: SavedObjectsClientContract, sourceId: string ): Promise { const staticDefaultSourceConfiguration = await this.getStaticDefaultSourceConfiguration(); @@ -55,7 +55,7 @@ export class InfraSources { })) .catch(err => err instanceof NotFoundError - ? this.getSavedSourceConfiguration(requestContext, sourceId).then(result => ({ + ? this.getSavedSourceConfiguration(savedObjectsClient, sourceId).then(result => ({ ...result, configuration: mergeSourceConfiguration( staticDefaultSourceConfiguration, @@ -65,7 +65,7 @@ export class InfraSources { : Promise.reject(err) ) .catch(err => - requestContext.core.savedObjects.client.errors.isNotFoundError(err) + savedObjectsClient.errors.isNotFoundError(err) ? Promise.resolve({ id: sourceId, version: undefined, @@ -136,7 +136,10 @@ export class InfraSources { ) { const staticDefaultSourceConfiguration = await this.getStaticDefaultSourceConfiguration(); - const { configuration, version } = await this.getSourceConfiguration(requestContext, sourceId); + const { configuration, version } = await this.getSourceConfiguration( + requestContext.core.savedObjects.client, + sourceId + ); const updatedSourceConfigurationAttributes = mergeSourceConfiguration( configuration, @@ -199,10 +202,10 @@ export class InfraSources { } private async getSavedSourceConfiguration( - requestContext: RequestHandlerContext, + savedObjectsClient: SavedObjectsClientContract, sourceId: string ) { - const savedObject = await requestContext.core.savedObjects.client.get( + const savedObject = await savedObjectsClient.get( infraSourceConfigurationSavedObjectType, sourceId ); diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index e3804078604cc..d4dfa60ac67a0 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -147,7 +147,7 @@ export class InfraServerPlugin { ]); initInfraServer(this.libs); - registerAlertTypes(plugins.alerting); + registerAlertTypes(plugins.alerting, this.libs); // Telemetry UsageCollector.registerUsageCollector(plugins.usageCollection); diff --git a/x-pack/plugins/infra/server/routes/inventory_metadata/index.ts b/x-pack/plugins/infra/server/routes/inventory_metadata/index.ts index 7e9b7ada28c8e..687e368736a41 100644 --- a/x-pack/plugins/infra/server/routes/inventory_metadata/index.ts +++ b/x-pack/plugins/infra/server/routes/inventory_metadata/index.ts @@ -39,7 +39,7 @@ export const initInventoryMetaRoute = (libs: InfraBackendLibs) => { ); const { configuration } = await libs.sources.getSourceConfiguration( - requestContext, + requestContext.core.savedObjects.client, sourceId ); const awsMetadata = await getCloudMetadata( diff --git a/x-pack/plugins/infra/server/routes/log_entries/item.ts b/x-pack/plugins/infra/server/routes/log_entries/item.ts index 3a6bdaf3804e3..85dba8f598a89 100644 --- a/x-pack/plugins/infra/server/routes/log_entries/item.ts +++ b/x-pack/plugins/infra/server/routes/log_entries/item.ts @@ -37,8 +37,9 @@ export const initLogEntriesItemRoute = ({ framework, sources, logEntries }: Infr ); const { id, sourceId } = payload; - const sourceConfiguration = (await sources.getSourceConfiguration(requestContext, sourceId)) - .configuration; + const sourceConfiguration = ( + await sources.getSourceConfiguration(requestContext.core.savedObjects.client, sourceId) + ).configuration; const logEntry = await logEntries.getLogItem(requestContext, id, sourceConfiguration); diff --git a/x-pack/plugins/infra/server/routes/metadata/index.ts b/x-pack/plugins/infra/server/routes/metadata/index.ts index c45f191b1130d..fe142aa93dcda 100644 --- a/x-pack/plugins/infra/server/routes/metadata/index.ts +++ b/x-pack/plugins/infra/server/routes/metadata/index.ts @@ -44,7 +44,7 @@ export const initMetadataRoute = (libs: InfraBackendLibs) => { ); const { configuration } = await libs.sources.getSourceConfiguration( - requestContext, + requestContext.core.savedObjects.client, sourceId ); const metricsMetadata = await getMetricMetadata( diff --git a/x-pack/plugins/infra/server/routes/node_details/index.ts b/x-pack/plugins/infra/server/routes/node_details/index.ts index 36906f6f4125b..a457ccac2416c 100644 --- a/x-pack/plugins/infra/server/routes/node_details/index.ts +++ b/x-pack/plugins/infra/server/routes/node_details/index.ts @@ -37,7 +37,10 @@ export const initNodeDetailsRoute = (libs: InfraBackendLibs) => { NodeDetailsRequestRT.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const source = await libs.sources.getSourceConfiguration(requestContext, sourceId); + const source = await libs.sources.getSourceConfiguration( + requestContext.core.savedObjects.client, + sourceId + ); UsageCollector.countNode(nodeType); diff --git a/x-pack/plugins/infra/server/routes/snapshot/index.ts b/x-pack/plugins/infra/server/routes/snapshot/index.ts index e45b9884967d0..d1dc03893a0d9 100644 --- a/x-pack/plugins/infra/server/routes/snapshot/index.ts +++ b/x-pack/plugins/infra/server/routes/snapshot/index.ts @@ -42,7 +42,10 @@ export const initSnapshotRoute = (libs: InfraBackendLibs) => { SnapshotRequestRT.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const source = await libs.sources.getSourceConfiguration(requestContext, sourceId); + const source = await libs.sources.getSourceConfiguration( + requestContext.core.savedObjects.client, + sourceId + ); UsageCollector.countNode(nodeType); const options = { filterQuery: parseFilterQuery(filterQuery), diff --git a/x-pack/plugins/infra/server/routes/source/index.ts b/x-pack/plugins/infra/server/routes/source/index.ts index 2f29320d7bb81..62b7fd7ba902f 100644 --- a/x-pack/plugins/infra/server/routes/source/index.ts +++ b/x-pack/plugins/infra/server/routes/source/index.ts @@ -37,7 +37,10 @@ export const initSourceRoute = (libs: InfraBackendLibs) => { try { const { type, sourceId } = request.params; - const source = await libs.sources.getSourceConfiguration(requestContext, sourceId); + const source = await libs.sources.getSourceConfiguration( + requestContext.core.savedObjects.client, + sourceId + ); if (!source) { return response.notFound(); } diff --git a/x-pack/plugins/maps/public/angular/get_initial_layers.js b/x-pack/plugins/maps/public/angular/get_initial_layers.js index 1eb5dac309f28..f02ded1704533 100644 --- a/x-pack/plugins/maps/public/angular/get_initial_layers.js +++ b/x-pack/plugins/maps/public/angular/get_initial_layers.js @@ -5,27 +5,18 @@ */ import _ from 'lodash'; // Import each layer type, even those not used, to init in registry - import '../layers/sources/wms_source'; - import '../layers/sources/ems_file_source'; - import '../layers/sources/es_search_source'; - -import '../layers/sources/es_pew_pew_source/es_pew_pew_source'; - +import '../layers/sources/es_pew_pew_source'; import '../layers/sources/kibana_regionmap_source'; - import '../layers/sources/es_geo_grid_source'; - import '../layers/sources/xyz_tms_source'; - import { KibanaTilemapSource } from '../layers/sources/kibana_tilemap_source'; - +import { TileLayer } from '../layers/tile_layer'; import { EMSTMSSource } from '../layers/sources/ems_tms_source'; - +import { VectorTileLayer } from '../layers/vector_tile_layer'; import { getInjectedVarFunc } from '../kibana_services'; - import { getKibanaTileMap } from '../meta'; export function getInitialLayers(layerListJSON, initialLayers = []) { @@ -35,18 +26,18 @@ export function getInitialLayers(layerListJSON, initialLayers = []) { const tilemapSourceFromKibana = getKibanaTileMap(); if (_.get(tilemapSourceFromKibana, 'url')) { - const sourceDescriptor = KibanaTilemapSource.createDescriptor(); - const source = new KibanaTilemapSource(sourceDescriptor); - const layer = source.createDefaultLayer(); - return [layer.toLayerDescriptor(), ...initialLayers]; + const layerDescriptor = TileLayer.createDescriptor({ + sourceDescriptor: KibanaTilemapSource.createDescriptor(), + }); + return [layerDescriptor, ...initialLayers]; } const isEmsEnabled = getInjectedVarFunc()('isEmsEnabled', true); if (isEmsEnabled) { - const descriptor = EMSTMSSource.createDescriptor({ isAutoSelect: true }); - const source = new EMSTMSSource(descriptor); - const layer = source.createDefaultLayer(); - return [layer.toLayerDescriptor(), ...initialLayers]; + const layerDescriptor = VectorTileLayer.createDescriptor({ + sourceDescriptor: EMSTMSSource.createDescriptor({ isAutoSelect: true }), + }); + return [layerDescriptor, ...initialLayers]; } return initialLayers; diff --git a/x-pack/plugins/maps/public/connected_components/layer_addpanel/import_editor/view.js b/x-pack/plugins/maps/public/connected_components/layer_addpanel/import_editor/view.js index a4fa0d492bf3f..8ebb17ac4fff5 100644 --- a/x-pack/plugins/maps/public/connected_components/layer_addpanel/import_editor/view.js +++ b/x-pack/plugins/maps/public/connected_components/layer_addpanel/import_editor/view.js @@ -4,51 +4,36 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment } from 'react'; -import { EuiSpacer, EuiPanel, EuiButtonEmpty } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; +import { EuiPanel } from '@elastic/eui'; import { uploadLayerWizardConfig } from '../../../layers/sources/client_file_source'; -export const ImportEditor = ({ clearSource, isIndexingTriggered, ...props }) => { - const editorProperties = getEditorProperties({ isIndexingTriggered, ...props }); +export const ImportEditor = props => { + const editorProperties = getEditorProperties(props); return ( - - {isIndexingTriggered ? null : ( - - - - - - - )} - - {uploadLayerWizardConfig.renderWizard(editorProperties)} - - + + {uploadLayerWizardConfig.renderWizard(editorProperties)} + ); }; function getEditorProperties({ - inspectorAdapters, + previewLayer, + mapColors, onRemove, - viewLayer, isIndexingTriggered, onIndexReady, importSuccessHandler, importErrorHandler, }) { return { - onPreviewSource: viewLayer, - inspectorAdapters, + previewLayer, + mapColors, onRemove, importSuccessHandler, importErrorHandler, isIndexingTriggered, - addAndViewSource: viewLayer, onIndexReady, }; } diff --git a/x-pack/plugins/maps/public/connected_components/layer_addpanel/index.js b/x-pack/plugins/maps/public/connected_components/layer_addpanel/index.js index 24c1f5ced4fe6..a29898f8a2830 100644 --- a/x-pack/plugins/maps/public/connected_components/layer_addpanel/index.js +++ b/x-pack/plugins/maps/public/connected_components/layer_addpanel/index.js @@ -34,12 +34,12 @@ function mapStateToProps(state = {}) { function mapDispatchToProps(dispatch) { return { - viewLayer: async layer => { + previewLayer: async layerDescriptor => { await dispatch(setSelectedLayer(null)); await dispatch(removeTransientLayer()); - dispatch(addLayer(layer.toLayerDescriptor())); - dispatch(setSelectedLayer(layer.getId())); - dispatch(setTransientLayer(layer.getId())); + dispatch(addLayer(layerDescriptor)); + dispatch(setSelectedLayer(layerDescriptor.id)); + dispatch(setTransientLayer(layerDescriptor.id)); }, removeTransientLayer: () => { dispatch(setSelectedLayer(null)); diff --git a/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_editor/index.js b/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_editor/index.js deleted file mode 100644 index 8937f32d3bf05..0000000000000 --- a/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_editor/index.js +++ /dev/null @@ -1,19 +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 { connect } from 'react-redux'; -import { SourceEditor } from './view'; - -import { getInspectorAdapters } from '../../../reducers/non_serializable_instances'; - -function mapStateToProps(state = {}) { - return { - inspectorAdapters: getInspectorAdapters(state), - }; -} - -const connectedFlyOut = connect(mapStateToProps)(SourceEditor); -export { connectedFlyOut as SourceEditor }; diff --git a/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_editor/view.js b/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_editor/view.js deleted file mode 100644 index 50312b68277fa..0000000000000 --- a/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_editor/view.js +++ /dev/null @@ -1,40 +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, { Fragment } from 'react'; -import { EuiSpacer, EuiPanel, EuiButtonEmpty } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; - -export const SourceEditor = ({ - clearSource, - layerWizard, - isIndexingTriggered, - inspectorAdapters, - previewLayer, -}) => { - if (!layerWizard) { - return null; - } - - return ( - - {isIndexingTriggered ? null : ( - - - - - - - )} - - {layerWizard.renderWizard({ onPreviewSource: previewLayer, inspectorAdapters })} - - - ); -}; diff --git a/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_select/source_select.js b/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_select/source_select.js index 80b05a0fd015b..82df9237e6ed3 100644 --- a/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_select/source_select.js +++ b/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_select/source_select.js @@ -7,8 +7,7 @@ import React, { Fragment } from 'react'; import { getLayerWizards } from '../../../layers/layer_wizard_registry'; -import { EuiTitle, EuiSpacer, EuiCard, EuiIcon } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiSpacer, EuiCard, EuiIcon } from '@elastic/eui'; import _ from 'lodash'; export function SourceSelect({ updateSourceSelection }) { @@ -38,17 +37,5 @@ export function SourceSelect({ updateSourceSelection }) { ); }); - return ( - - -

- -

-
- {sourceCards} -
- ); + return {sourceCards}; } diff --git a/x-pack/plugins/maps/public/connected_components/layer_addpanel/view.js b/x-pack/plugins/maps/public/connected_components/layer_addpanel/view.js index 92fcf01f3901f..127b99d730db5 100644 --- a/x-pack/plugins/maps/public/connected_components/layer_addpanel/view.js +++ b/x-pack/plugins/maps/public/connected_components/layer_addpanel/view.js @@ -4,18 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Component } from 'react'; +import React, { Component, Fragment } from 'react'; import { SourceSelect } from './source_select/source_select'; import { FlyoutFooter } from './flyout_footer'; -import { SourceEditor } from './source_editor'; import { ImportEditor } from './import_editor'; -import { EuiFlexGroup, EuiTitle, EuiFlyoutHeader } from '@elastic/eui'; +import { EuiButtonEmpty, EuiPanel, EuiTitle, EuiFlyoutHeader, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; export class AddLayerPanel extends Component { state = { layerWizard: null, - layer: null, + layerDescriptor: null, // TODO get this from redux store instead of storing locally + isIndexingSource: false, importView: false, layerImportAddReady: false, }; @@ -35,13 +36,9 @@ export class AddLayerPanel extends Component { } _getPanelDescription() { - const { layerWizard, importView, layerImportAddReady } = this.state; + const { importView, layerImportAddReady } = this.state; let panelDescription; - if (!layerWizard) { - panelDescription = i18n.translate('xpack.maps.addLayerPanel.selectSource', { - defaultMessage: 'Select source', - }); - } else if (layerImportAddReady || !importView) { + if (layerImportAddReady || !importView) { panelDescription = i18n.translate('xpack.maps.addLayerPanel.addLayer', { defaultMessage: 'Add layer', }); @@ -53,29 +50,21 @@ export class AddLayerPanel extends Component { return panelDescription; } - _viewLayer = async (source, options = {}) => { + _previewLayer = async (layerDescriptor, isIndexingSource) => { if (!this._isMounted) { return; } - if (!source) { - this.setState({ layer: null }); + if (!layerDescriptor) { + this.setState({ + layerDescriptor: null, + isIndexingSource: false, + }); this.props.removeTransientLayer(); return; } - const styleDescriptor = - this.state.layer && this.state.layer.getCurrentStyle() - ? this.state.layer.getCurrentStyle().getDescriptor() - : null; - const layerInitProps = { - ...options, - style: styleDescriptor, - }; - const newLayer = source.createDefaultLayer(layerInitProps, this.props.mapColors); - if (!this._isMounted) { - return; - } - this.setState({ layer: newLayer }, () => this.props.viewLayer(this.state.layer)); + this.setState({ layerDescriptor, isIndexingSource }); + this.props.previewLayer(layerDescriptor); }; _clearLayerData = ({ keepSourceType = false }) => { @@ -84,7 +73,8 @@ export class AddLayerPanel extends Component { } this.setState({ - layer: null, + layerDescriptor: null, + isIndexingSource: false, ...(!keepSourceType ? { layerWizard: null, importView: false } : {}), }); this.props.removeTransientLayer(); @@ -95,72 +85,75 @@ export class AddLayerPanel extends Component { }; _layerAddHandler = () => { - const { - isIndexingTriggered, - setIndexingTriggered, - selectLayerAndAdd, - resetIndexing, - } = this.props; - const layerSource = this.state.layer.getSource(); - const boolIndexLayer = layerSource.shouldBeIndexed(); - this.setState({ layer: null }); - if (boolIndexLayer && !isIndexingTriggered) { - setIndexingTriggered(); + if (this.state.isIndexingSource && !this.props.isIndexingTriggered) { + this.props.setIndexingTriggered(); } else { - selectLayerAndAdd(); + this.props.selectLayerAndAdd(); if (this.state.importView) { this.setState({ layerImportAddReady: false, }); - resetIndexing(); + this.props.resetIndexing(); } } }; - _renderAddLayerPanel() { - const { layerWizard, importView } = this.state; - if (!layerWizard) { + _renderPanelBody() { + if (!this.state.layerWizard) { return ; } - if (importView) { + + const backButton = this.props.isIndexingTriggered ? null : ( + + + + + + + ); + + if (this.state.importView) { return ( - this._clearLayerData({ keepSourceType: true })} - /> + + {backButton} + this._clearLayerData({ keepSourceType: true })} + /> + ); } - return ( - - ); - } - - _renderFooter(buttonDescription) { - const { importView, layer } = this.state; - const { isIndexingReady, isIndexingSuccess } = this.props; - - const buttonEnabled = importView ? isIndexingReady || isIndexingSuccess : !!layer; return ( - + + {backButton} + + {this.state.layerWizard.renderWizard({ + previewLayer: this._previewLayer, + mapColors: this.props.mapColors, + })} + + ); } - _renderFlyout() { + render() { + if (!this.props.flyoutVisible) { + return null; + } + const panelDescription = this._getPanelDescription(); + const isNextBtnEnabled = this.state.importView + ? this.props.isIndexingReady || this.props.isIndexingSuccess + : !!this.state.layerDescriptor; return ( - +

{panelDescription}

@@ -168,14 +161,16 @@ export class AddLayerPanel extends Component {
-
{this._renderAddLayerPanel()}
+
{this._renderPanelBody()}
- {this._renderFooter(panelDescription)} -
- ); - } - render() { - return this.props.flyoutVisible ? this._renderFlyout() : null; + + + ); } } diff --git a/x-pack/plugins/maps/public/layers/blended_vector_layer.ts b/x-pack/plugins/maps/public/layers/blended_vector_layer.ts index 1fc3ad203706f..5c486200977d7 100644 --- a/x-pack/plugins/maps/public/layers/blended_vector_layer.ts +++ b/x-pack/plugins/maps/public/layers/blended_vector_layer.ts @@ -156,7 +156,7 @@ export class BlendedVectorLayer extends VectorLayer implements IVectorLayer { static type = LAYER_TYPE.BLENDED_VECTOR; static createDescriptor( - options: VectorLayerDescriptor, + options: Partial, mapColors: string[] ): VectorLayerDescriptor { const layerDescriptor = VectorLayer.createDescriptor(options, mapColors); diff --git a/x-pack/plugins/maps/public/layers/layer.tsx b/x-pack/plugins/maps/public/layers/layer.tsx index ce48793e1481b..13fe447cec3da 100644 --- a/x-pack/plugins/maps/public/layers/layer.tsx +++ b/x-pack/plugins/maps/public/layers/layer.tsx @@ -62,7 +62,6 @@ export interface ILayer { isLayerLoading(): boolean; hasErrors(): boolean; getErrors(): string; - toLayerDescriptor(): LayerDescriptor; getMbLayerIds(): string[]; ownsMbLayerId(mbLayerId: string): boolean; ownsMbSourceId(mbSourceId: string): boolean; @@ -413,10 +412,6 @@ export class AbstractLayer implements ILayer { : ''; } - toLayerDescriptor(): LayerDescriptor { - return this._descriptor; - } - async syncData(syncContext: SyncContext) { // no-op by default } diff --git a/x-pack/plugins/maps/public/layers/layer_wizard_registry.ts b/x-pack/plugins/maps/public/layers/layer_wizard_registry.ts index cb87aeaa9da3f..633e8c86d8c94 100644 --- a/x-pack/plugins/maps/public/layers/layer_wizard_registry.ts +++ b/x-pack/plugins/maps/public/layers/layer_wizard_registry.ts @@ -6,13 +6,17 @@ /* eslint-disable @typescript-eslint/consistent-type-definitions */ import { ReactElement } from 'react'; -import { ISource } from './sources/source'; - -export type PreviewSourceHandler = (source: ISource | null) => void; +import { LayerDescriptor } from '../../common/descriptor_types'; export type RenderWizardArguments = { - onPreviewSource: PreviewSourceHandler; - inspectorAdapters: object; + previewLayer: (layerDescriptor: LayerDescriptor | null, isIndexingSource?: boolean) => void; + mapColors: string[]; + // upload arguments + isIndexingTriggered: boolean; + onRemove: () => void; + onIndexReady: () => void; + importSuccessHandler: (indexResponses: unknown) => void; + importErrorHandler: (indexResponses: unknown) => void; }; export type LayerWizard = { diff --git a/x-pack/plugins/maps/public/layers/load_layer_wizards.ts b/x-pack/plugins/maps/public/layers/load_layer_wizards.ts index 49d128257fe20..0b83f3bbdc613 100644 --- a/x-pack/plugins/maps/public/layers/load_layer_wizards.ts +++ b/x-pack/plugins/maps/public/layers/load_layer_wizards.ts @@ -12,7 +12,7 @@ import { esDocumentsLayerWizardConfig } from './sources/es_search_source'; // @ts-ignore import { clustersLayerWizardConfig, heatmapLayerWizardConfig } from './sources/es_geo_grid_source'; // @ts-ignore -import { point2PointLayerWizardConfig } from './sources/es_pew_pew_source/es_pew_pew_source'; +import { point2PointLayerWizardConfig } from './sources/es_pew_pew_source'; // @ts-ignore import { emsBoundariesLayerWizardConfig } from './sources/ems_file_source'; // @ts-ignore diff --git a/x-pack/plugins/maps/public/layers/sources/client_file_source/geojson_file_source.js b/x-pack/plugins/maps/public/layers/sources/client_file_source/geojson_file_source.js index 36f898f723757..3c9c71d2a1875 100644 --- a/x-pack/plugins/maps/public/layers/sources/client_file_source/geojson_file_source.js +++ b/x-pack/plugins/maps/public/layers/sources/client_file_source/geojson_file_source.js @@ -5,17 +5,7 @@ */ import { AbstractVectorSource } from '../vector_source'; -import React from 'react'; -import { - ES_GEO_FIELD_TYPE, - SOURCE_TYPES, - DEFAULT_MAX_RESULT_WINDOW, - SCALING_TYPES, -} from '../../../../common/constants'; -import { ClientFileCreateSourceEditor } from './create_client_file_source_editor'; -import { ESSearchSource } from '../es_search_source'; -import uuid from 'uuid/v4'; -import { i18n } from '@kbn/i18n'; +import { SOURCE_TYPES } from '../../../../common/constants'; import { registerSource } from '../source_registry'; export class GeojsonFileSource extends AbstractVectorSource { @@ -66,103 +56,9 @@ export class GeojsonFileSource extends AbstractVectorSource { canFormatFeatureProperties() { return true; } - - shouldBeIndexed() { - return true; - } } -const viewIndexedData = ( - addAndViewSource, - inspectorAdapters, - importSuccessHandler, - importErrorHandler -) => { - return (indexResponses = {}) => { - const { indexDataResp, indexPatternResp } = indexResponses; - - const indexCreationFailed = !(indexDataResp && indexDataResp.success); - const allDocsFailed = indexDataResp.failures.length === indexDataResp.docCount; - const indexPatternCreationFailed = !(indexPatternResp && indexPatternResp.success); - - if (indexCreationFailed || allDocsFailed || indexPatternCreationFailed) { - importErrorHandler(indexResponses); - return; - } - const { fields, id: indexPatternId } = indexPatternResp; - const geoField = fields.find(field => Object.values(ES_GEO_FIELD_TYPE).includes(field.type)); - if (!indexPatternId || !geoField) { - addAndViewSource(null); - } else { - const source = new ESSearchSource( - { - id: uuid(), - indexPatternId, - geoField: geoField.name, - // Only turn on bounds filter for large doc counts - filterByMapBounds: indexDataResp.docCount > DEFAULT_MAX_RESULT_WINDOW, - scalingType: - geoField.type === ES_GEO_FIELD_TYPE.GEO_POINT - ? SCALING_TYPES.CLUSTERS - : SCALING_TYPES.LIMIT, - }, - inspectorAdapters - ); - addAndViewSource(source); - importSuccessHandler(indexResponses); - } - }; -}; - -const previewGeojsonFile = (onPreviewSource, inspectorAdapters) => { - return (geojsonFile, name) => { - if (!geojsonFile) { - onPreviewSource(null); - return; - } - const sourceDescriptor = GeojsonFileSource.createDescriptor(geojsonFile, name); - const source = new GeojsonFileSource(sourceDescriptor, inspectorAdapters); - onPreviewSource(source); - }; -}; - registerSource({ ConstructorFunction: GeojsonFileSource, type: SOURCE_TYPES.GEOJSON_FILE, }); - -export const uploadLayerWizardConfig = { - description: i18n.translate('xpack.maps.source.geojsonFileDescription', { - defaultMessage: 'Index GeoJSON data in Elasticsearch', - }), - icon: 'importAction', - isIndexingSource: true, - renderWizard: ({ - onPreviewSource, - inspectorAdapters, - addAndViewSource, - isIndexingTriggered, - onRemove, - onIndexReady, - importSuccessHandler, - importErrorHandler, - }) => { - return ( - - ); - }, - title: i18n.translate('xpack.maps.source.geojsonFileTitle', { - defaultMessage: 'Upload GeoJSON', - }), -}; diff --git a/x-pack/plugins/maps/public/layers/sources/client_file_source/index.js b/x-pack/plugins/maps/public/layers/sources/client_file_source/index.js index a6a31def4b231..5c2a0afd31885 100644 --- a/x-pack/plugins/maps/public/layers/sources/client_file_source/index.js +++ b/x-pack/plugins/maps/public/layers/sources/client_file_source/index.js @@ -4,4 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { GeojsonFileSource, uploadLayerWizardConfig } from './geojson_file_source'; +export { GeojsonFileSource } from './geojson_file_source'; +export { uploadLayerWizardConfig } from './upload_layer_wizard'; diff --git a/x-pack/plugins/maps/public/layers/sources/client_file_source/upload_layer_wizard.tsx b/x-pack/plugins/maps/public/layers/sources/client_file_source/upload_layer_wizard.tsx new file mode 100644 index 0000000000000..2f8aa67d74b52 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/client_file_source/upload_layer_wizard.tsx @@ -0,0 +1,106 @@ +/* + * 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'; +import React from 'react'; +import { IFieldType } from 'src/plugins/data/public'; +import { + ES_GEO_FIELD_TYPE, + DEFAULT_MAX_RESULT_WINDOW, + SCALING_TYPES, +} from '../../../../common/constants'; +// @ts-ignore +import { ESSearchSource, createDefaultLayerDescriptor } from '../es_search_source'; +import { LayerWizard, RenderWizardArguments } from '../../layer_wizard_registry'; +// @ts-ignore +import { ClientFileCreateSourceEditor } from './create_client_file_source_editor'; +// @ts-ignore +import { GeojsonFileSource } from './geojson_file_source'; +import { VectorLayer } from '../../vector_layer'; + +export const uploadLayerWizardConfig: LayerWizard = { + description: i18n.translate('xpack.maps.source.geojsonFileDescription', { + defaultMessage: 'Index GeoJSON data in Elasticsearch', + }), + icon: 'importAction', + isIndexingSource: true, + renderWizard: ({ + previewLayer, + mapColors, + isIndexingTriggered, + onRemove, + onIndexReady, + importSuccessHandler, + importErrorHandler, + }: RenderWizardArguments) => { + function previewGeojsonFile(geojsonFile: unknown, name: string) { + if (!geojsonFile) { + previewLayer(null); + return; + } + const sourceDescriptor = GeojsonFileSource.createDescriptor(geojsonFile, name); + const layerDescriptor = VectorLayer.createDescriptor({ sourceDescriptor }, mapColors); + // TODO figure out a better way to handle passing this information back to layer_addpanel + previewLayer(layerDescriptor, true); + } + + function viewIndexedData(indexResponses: { + indexDataResp: unknown; + indexPatternResp: unknown; + }) { + const { indexDataResp, indexPatternResp } = indexResponses; + + // @ts-ignore + const indexCreationFailed = !(indexDataResp && indexDataResp.success); + // @ts-ignore + const allDocsFailed = indexDataResp.failures.length === indexDataResp.docCount; + // @ts-ignore + const indexPatternCreationFailed = !(indexPatternResp && indexPatternResp.success); + + if (indexCreationFailed || allDocsFailed || indexPatternCreationFailed) { + importErrorHandler(indexResponses); + return; + } + // @ts-ignore + const { fields, id: indexPatternId } = indexPatternResp; + const geoField = fields.find((field: IFieldType) => + [ES_GEO_FIELD_TYPE.GEO_POINT as string, ES_GEO_FIELD_TYPE.GEO_SHAPE as string].includes( + field.type + ) + ); + if (!indexPatternId || !geoField) { + previewLayer(null); + } else { + const esSearchSourceConfig = { + indexPatternId, + geoField: geoField.name, + // Only turn on bounds filter for large doc counts + // @ts-ignore + filterByMapBounds: indexDataResp.docCount > DEFAULT_MAX_RESULT_WINDOW, + scalingType: + geoField.type === ES_GEO_FIELD_TYPE.GEO_POINT + ? SCALING_TYPES.CLUSTERS + : SCALING_TYPES.LIMIT, + }; + previewLayer(createDefaultLayerDescriptor(esSearchSourceConfig, mapColors)); + importSuccessHandler(indexResponses); + } + } + + return ( + + ); + }, + title: i18n.translate('xpack.maps.source.geojsonFileTitle', { + defaultMessage: 'Upload GeoJSON', + }), +}; diff --git a/x-pack/plugins/maps/public/layers/sources/ems_file_source/ems_boundaries_layer_wizard.tsx b/x-pack/plugins/maps/public/layers/sources/ems_file_source/ems_boundaries_layer_wizard.tsx new file mode 100644 index 0000000000000..f31e770df2d95 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/ems_file_source/ems_boundaries_layer_wizard.tsx @@ -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 React from 'react'; +import { i18n } from '@kbn/i18n'; +import { VectorLayer } from '../../vector_layer'; +import { LayerWizard, RenderWizardArguments } from '../../layer_wizard_registry'; +// @ts-ignore +import { EMSFileCreateSourceEditor } from './create_source_editor'; +// @ts-ignore +import { EMSFileSource, sourceTitle } from './ems_file_source'; + +export const emsBoundariesLayerWizardConfig: LayerWizard = { + description: i18n.translate('xpack.maps.source.emsFileDescription', { + defaultMessage: 'Administrative boundaries from Elastic Maps Service', + }), + icon: 'emsApp', + renderWizard: ({ previewLayer, mapColors }: RenderWizardArguments) => { + const onSourceConfigChange = (sourceConfig: unknown) => { + // @ts-ignore + const sourceDescriptor = EMSFileSource.createDescriptor(sourceConfig); + const layerDescriptor = VectorLayer.createDescriptor({ sourceDescriptor }, mapColors); + previewLayer(layerDescriptor); + }; + return ; + }, + title: sourceTitle, +}; diff --git a/x-pack/plugins/maps/public/layers/sources/ems_file_source/ems_file_source.js b/x-pack/plugins/maps/public/layers/sources/ems_file_source/ems_file_source.js index e8af17b911939..5802a223e4846 100644 --- a/x-pack/plugins/maps/public/layers/sources/ems_file_source/ems_file_source.js +++ b/x-pack/plugins/maps/public/layers/sources/ems_file_source/ems_file_source.js @@ -9,14 +9,13 @@ import { VECTOR_SHAPE_TYPES } from '../vector_feature_types'; import React from 'react'; import { SOURCE_TYPES, FIELD_ORIGIN } from '../../../../common/constants'; import { getEMSClient } from '../../../meta'; -import { EMSFileCreateSourceEditor } from './create_source_editor'; import { i18n } from '@kbn/i18n'; import { getDataSourceLabel } from '../../../../common/i18n_getters'; import { UpdateSourceEditor } from './update_source_editor'; import { EMSFileField } from '../../fields/ems_file_field'; import { registerSource } from '../source_registry'; -const sourceTitle = i18n.translate('xpack.maps.source.emsFileTitle', { +export const sourceTitle = i18n.translate('xpack.maps.source.emsFileTitle', { defaultMessage: 'EMS Boundaries', }); @@ -161,19 +160,3 @@ registerSource({ ConstructorFunction: EMSFileSource, type: SOURCE_TYPES.EMS_FILE, }); - -export const emsBoundariesLayerWizardConfig = { - description: i18n.translate('xpack.maps.source.emsFileDescription', { - defaultMessage: 'Administrative boundaries from Elastic Maps Service', - }), - icon: 'emsApp', - renderWizard: ({ onPreviewSource, inspectorAdapters }) => { - const onSourceConfigChange = sourceConfig => { - const sourceDescriptor = EMSFileSource.createDescriptor(sourceConfig); - const source = new EMSFileSource(sourceDescriptor, inspectorAdapters); - onPreviewSource(source); - }; - return ; - }, - title: sourceTitle, -}; diff --git a/x-pack/plugins/maps/public/layers/sources/ems_file_source/index.js b/x-pack/plugins/maps/public/layers/sources/ems_file_source/index.js index 28fbc04a1a032..e9bf592c6d2b7 100644 --- a/x-pack/plugins/maps/public/layers/sources/ems_file_source/index.js +++ b/x-pack/plugins/maps/public/layers/sources/ems_file_source/index.js @@ -4,4 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { EMSFileSource, emsBoundariesLayerWizardConfig } from './ems_file_source'; +export { emsBoundariesLayerWizardConfig } from './ems_boundaries_layer_wizard'; +export { EMSFileSource } from './ems_file_source'; diff --git a/x-pack/plugins/maps/public/layers/sources/ems_tms_source/ems_base_map_layer_wizard.tsx b/x-pack/plugins/maps/public/layers/sources/ems_tms_source/ems_base_map_layer_wizard.tsx new file mode 100644 index 0000000000000..ced33a0bcf84a --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/ems_tms_source/ems_base_map_layer_wizard.tsx @@ -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 React from 'react'; +import { i18n } from '@kbn/i18n'; +import { LayerWizard, RenderWizardArguments } from '../../layer_wizard_registry'; +// @ts-ignore +import { EMSTMSSource, sourceTitle } from './ems_tms_source'; +// @ts-ignore +import { VectorTileLayer } from '../../vector_tile_layer'; +// @ts-ignore +import { TileServiceSelect } from './tile_service_select'; + +export const emsBaseMapLayerWizardConfig: LayerWizard = { + description: i18n.translate('xpack.maps.source.emsTileDescription', { + defaultMessage: 'Tile map service from Elastic Maps Service', + }), + icon: 'emsApp', + renderWizard: ({ previewLayer }: RenderWizardArguments) => { + const onSourceConfigChange = (sourceConfig: unknown) => { + const layerDescriptor = VectorTileLayer.createDescriptor({ + sourceDescriptor: EMSTMSSource.createDescriptor(sourceConfig), + }); + previewLayer(layerDescriptor); + }; + + return ; + }, + title: sourceTitle, +}; diff --git a/x-pack/plugins/maps/public/layers/sources/ems_tms_source/ems_tms_source.js b/x-pack/plugins/maps/public/layers/sources/ems_tms_source/ems_tms_source.js index 79121c4cdb31f..3bed9b2c09570 100644 --- a/x-pack/plugins/maps/public/layers/sources/ems_tms_source/ems_tms_source.js +++ b/x-pack/plugins/maps/public/layers/sources/ems_tms_source/ems_tms_source.js @@ -7,10 +7,8 @@ import _ from 'lodash'; import React from 'react'; import { AbstractTMSSource } from '../tms_source'; -import { VectorTileLayer } from '../../vector_tile_layer'; import { getEMSClient } from '../../../meta'; -import { TileServiceSelect } from './tile_service_select'; import { UpdateSourceEditor } from './update_source_editor'; import { i18n } from '@kbn/i18n'; import { getDataSourceLabel } from '../../../../common/i18n_getters'; @@ -18,7 +16,7 @@ import { SOURCE_TYPES } from '../../../../common/constants'; import { getInjectedVarFunc, getUiSettings } from '../../../kibana_services'; import { registerSource } from '../source_registry'; -const sourceTitle = i18n.translate('xpack.maps.source.emsTileTitle', { +export const sourceTitle = i18n.translate('xpack.maps.source.emsTileTitle', { defaultMessage: 'EMS Basemaps', }); @@ -84,20 +82,6 @@ export class EMSTMSSource extends AbstractTMSSource { return tmsService; } - _createDefaultLayerDescriptor(options) { - return VectorTileLayer.createDescriptor({ - sourceDescriptor: this._descriptor, - ...options, - }); - } - - createDefaultLayer(options) { - return new VectorTileLayer({ - layerDescriptor: this._createDefaultLayerDescriptor(options), - source: this, - }); - } - async getDisplayName() { try { const emsTMSService = await this._getEMSTMSService(); @@ -150,20 +134,3 @@ registerSource({ ConstructorFunction: EMSTMSSource, type: SOURCE_TYPES.EMS_TMS, }); - -export const emsBaseMapLayerWizardConfig = { - description: i18n.translate('xpack.maps.source.emsTileDescription', { - defaultMessage: 'Tile map service from Elastic Maps Service', - }), - icon: 'emsApp', - renderWizard: ({ onPreviewSource, inspectorAdapters }) => { - const onSourceConfigChange = sourceConfig => { - const descriptor = EMSTMSSource.createDescriptor(sourceConfig); - const source = new EMSTMSSource(descriptor, inspectorAdapters); - onPreviewSource(source); - }; - - return ; - }, - title: sourceTitle, -}; diff --git a/x-pack/plugins/maps/public/layers/sources/ems_tms_source/index.js b/x-pack/plugins/maps/public/layers/sources/ems_tms_source/index.js index 60a4c9b1de891..704bcfd370a85 100644 --- a/x-pack/plugins/maps/public/layers/sources/ems_tms_source/index.js +++ b/x-pack/plugins/maps/public/layers/sources/ems_tms_source/index.js @@ -4,4 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { EMSTMSSource, emsBaseMapLayerWizardConfig } from './ems_tms_source'; +export { emsBaseMapLayerWizardConfig } from './ems_base_map_layer_wizard'; +export { EMSTMSSource } from './ems_tms_source'; diff --git a/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/clusters_layer_wizard.tsx b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/clusters_layer_wizard.tsx new file mode 100644 index 0000000000000..f9092e64833f1 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/clusters_layer_wizard.tsx @@ -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 { i18n } from '@kbn/i18n'; +import React from 'react'; +// @ts-ignore +import { CreateSourceEditor } from './create_source_editor'; +// @ts-ignore +import { ESGeoGridSource, clustersTitle } from './es_geo_grid_source'; +import { LayerWizard, RenderWizardArguments } from '../../layer_wizard_registry'; +import { VectorLayer } from '../../vector_layer'; +import { + ESGeoGridSourceDescriptor, + ColorDynamicOptions, + SizeDynamicOptions, +} from '../../../../common/descriptor_types'; +import { getDefaultDynamicProperties } from '../../styles/vector/vector_style_defaults'; +import { VectorStyle } from '../../styles/vector/vector_style'; +import { + COUNT_PROP_NAME, + COLOR_MAP_TYPE, + FIELD_ORIGIN, + RENDER_AS, + VECTOR_STYLES, + STYLE_TYPE, +} from '../../../../common/constants'; +// @ts-ignore +import { COLOR_GRADIENTS } from '../../styles/color_utils'; + +export const clustersLayerWizardConfig: LayerWizard = { + description: i18n.translate('xpack.maps.source.esGridClustersDescription', { + defaultMessage: 'Geospatial data grouped in grids with metrics for each gridded cell', + }), + icon: 'logoElasticsearch', + renderWizard: ({ previewLayer }: RenderWizardArguments) => { + const onSourceConfigChange = (sourceConfig: Partial) => { + if (!sourceConfig) { + previewLayer(null); + return; + } + + const defaultDynamicProperties = getDefaultDynamicProperties(); + const layerDescriptor = VectorLayer.createDescriptor({ + sourceDescriptor: ESGeoGridSource.createDescriptor(sourceConfig), + style: VectorStyle.createDescriptor({ + // @ts-ignore + [VECTOR_STYLES.FILL_COLOR]: { + type: STYLE_TYPE.DYNAMIC, + options: { + ...(defaultDynamicProperties[VECTOR_STYLES.FILL_COLOR]! + .options as ColorDynamicOptions), + field: { + name: COUNT_PROP_NAME, + origin: FIELD_ORIGIN.SOURCE, + }, + color: COLOR_GRADIENTS[0].value, + type: COLOR_MAP_TYPE.ORDINAL, + }, + }, + [VECTOR_STYLES.LINE_COLOR]: { + type: STYLE_TYPE.STATIC, + options: { + color: '#FFF', + }, + }, + [VECTOR_STYLES.LINE_WIDTH]: { + type: STYLE_TYPE.STATIC, + options: { + size: 0, + }, + }, + [VECTOR_STYLES.ICON_SIZE]: { + type: STYLE_TYPE.DYNAMIC, + options: { + ...(defaultDynamicProperties[VECTOR_STYLES.ICON_SIZE]!.options as SizeDynamicOptions), + field: { + name: COUNT_PROP_NAME, + origin: FIELD_ORIGIN.SOURCE, + }, + }, + }, + [VECTOR_STYLES.LABEL_TEXT]: { + type: STYLE_TYPE.DYNAMIC, + options: { + ...defaultDynamicProperties[VECTOR_STYLES.LABEL_TEXT]!.options, + field: { + name: COUNT_PROP_NAME, + origin: FIELD_ORIGIN.SOURCE, + }, + }, + }, + }), + }); + previewLayer(layerDescriptor); + }; + + return ( + + ); + }, + title: clustersTitle, +}; diff --git a/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js index b9ef13e520bf8..17fad2f2e6453 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js +++ b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js @@ -8,39 +8,27 @@ import React from 'react'; import uuid from 'uuid/v4'; import { VECTOR_SHAPE_TYPES } from '../vector_feature_types'; -import { HeatmapLayer } from '../../heatmap_layer'; -import { VectorLayer } from '../../vector_layer'; import { convertCompositeRespToGeoJson, convertRegularRespToGeoJson } from './convert_to_geojson'; -import { VectorStyle } from '../../styles/vector/vector_style'; -import { getDefaultDynamicProperties } from '../../styles/vector/vector_style_defaults'; -import { COLOR_GRADIENTS } from '../../styles/color_utils'; -import { CreateSourceEditor } from './create_source_editor'; import { UpdateSourceEditor } from './update_source_editor'; import { SOURCE_TYPES, DEFAULT_MAX_BUCKETS_LIMIT, - COUNT_PROP_NAME, - COLOR_MAP_TYPE, RENDER_AS, GRID_RESOLUTION, - VECTOR_STYLES, - FIELD_ORIGIN, } from '../../../../common/constants'; import { i18n } from '@kbn/i18n'; import { getDataSourceLabel } from '../../../../common/i18n_getters'; import { AbstractESAggSource } from '../es_agg_source'; -import { DynamicStyleProperty } from '../../styles/vector/properties/dynamic_style_property'; -import { StaticStyleProperty } from '../../styles/vector/properties/static_style_property'; import { DataRequestAbortError } from '../../util/data_request'; import { registerSource } from '../source_registry'; export const MAX_GEOTILE_LEVEL = 29; -const clustersTitle = i18n.translate('xpack.maps.source.esGridClustersTitle', { +export const clustersTitle = i18n.translate('xpack.maps.source.esGridClustersTitle', { defaultMessage: 'Clusters and grids', }); -const heatmapTitle = i18n.translate('xpack.maps.source.esGridHeatmapTitle', { +export const heatmapTitle = i18n.translate('xpack.maps.source.esGridHeatmapTitle', { defaultMessage: 'Heat map', }); @@ -320,87 +308,6 @@ export class ESGeoGridSource extends AbstractESAggSource { return true; } - _createHeatmapLayerDescriptor(options) { - return HeatmapLayer.createDescriptor({ - sourceDescriptor: this._descriptor, - ...options, - }); - } - - _createVectorLayerDescriptor(options) { - const descriptor = VectorLayer.createDescriptor({ - sourceDescriptor: this._descriptor, - ...options, - }); - - const defaultDynamicProperties = getDefaultDynamicProperties(); - - descriptor.style = VectorStyle.createDescriptor({ - [VECTOR_STYLES.FILL_COLOR]: { - type: DynamicStyleProperty.type, - options: { - ...defaultDynamicProperties[VECTOR_STYLES.FILL_COLOR].options, - field: { - name: COUNT_PROP_NAME, - origin: FIELD_ORIGIN.SOURCE, - }, - color: COLOR_GRADIENTS[0].value, - type: COLOR_MAP_TYPE.ORDINAL, - }, - }, - [VECTOR_STYLES.LINE_COLOR]: { - type: StaticStyleProperty.type, - options: { - color: '#FFF', - }, - }, - [VECTOR_STYLES.LINE_WIDTH]: { - type: StaticStyleProperty.type, - options: { - size: 0, - }, - }, - [VECTOR_STYLES.ICON_SIZE]: { - type: DynamicStyleProperty.type, - options: { - ...defaultDynamicProperties[VECTOR_STYLES.ICON_SIZE].options, - field: { - name: COUNT_PROP_NAME, - origin: FIELD_ORIGIN.SOURCE, - }, - }, - }, - [VECTOR_STYLES.LABEL_TEXT]: { - type: DynamicStyleProperty.type, - options: { - ...defaultDynamicProperties[VECTOR_STYLES.LABEL_TEXT].options, - field: { - name: COUNT_PROP_NAME, - origin: FIELD_ORIGIN.SOURCE, - }, - }, - }, - }); - return descriptor; - } - - createDefaultLayer(options) { - if (this._descriptor.requestType === RENDER_AS.HEATMAP) { - return new HeatmapLayer({ - layerDescriptor: this._createHeatmapLayerDescriptor(options), - source: this, - }); - } - - const layerDescriptor = this._createVectorLayerDescriptor(options); - const style = new VectorStyle(layerDescriptor.style, this); - return new VectorLayer({ - layerDescriptor, - source: this, - style, - }); - } - canFormatFeatureProperties() { return true; } @@ -422,57 +329,3 @@ registerSource({ ConstructorFunction: ESGeoGridSource, type: SOURCE_TYPES.ES_GEO_GRID, }); - -export const clustersLayerWizardConfig = { - description: i18n.translate('xpack.maps.source.esGridClustersDescription', { - defaultMessage: 'Geospatial data grouped in grids with metrics for each gridded cell', - }), - icon: 'logoElasticsearch', - renderWizard: ({ onPreviewSource, inspectorAdapters }) => { - const onSourceConfigChange = sourceConfig => { - if (!sourceConfig) { - onPreviewSource(null); - return; - } - - const sourceDescriptor = ESGeoGridSource.createDescriptor(sourceConfig); - const source = new ESGeoGridSource(sourceDescriptor, inspectorAdapters); - onPreviewSource(source); - }; - - return ( - - ); - }, - title: clustersTitle, -}; - -export const heatmapLayerWizardConfig = { - description: i18n.translate('xpack.maps.source.esGridHeatmapDescription', { - defaultMessage: 'Geospatial data grouped in grids to show density', - }), - icon: 'logoElasticsearch', - renderWizard: ({ onPreviewSource, inspectorAdapters }) => { - const onSourceConfigChange = sourceConfig => { - if (!sourceConfig) { - onPreviewSource(null); - return; - } - - const sourceDescriptor = ESGeoGridSource.createDescriptor(sourceConfig); - const source = new ESGeoGridSource(sourceDescriptor, inspectorAdapters); - onPreviewSource(source); - }; - - return ( - - ); - }, - title: heatmapTitle, -}; diff --git a/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/heatmap_layer_wizard.tsx b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/heatmap_layer_wizard.tsx new file mode 100644 index 0000000000000..fee1a81a5c63a --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/heatmap_layer_wizard.tsx @@ -0,0 +1,45 @@ +/* + * 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'; +import React from 'react'; +// @ts-ignore +import { CreateSourceEditor } from './create_source_editor'; +// @ts-ignore +import { ESGeoGridSource, heatmapTitle } from './es_geo_grid_source'; +import { LayerWizard, RenderWizardArguments } from '../../layer_wizard_registry'; +// @ts-ignore +import { HeatmapLayer } from '../../heatmap_layer'; +import { ESGeoGridSourceDescriptor } from '../../../../common/descriptor_types'; +import { RENDER_AS } from '../../../../common/constants'; + +export const heatmapLayerWizardConfig: LayerWizard = { + description: i18n.translate('xpack.maps.source.esGridHeatmapDescription', { + defaultMessage: 'Geospatial data grouped in grids to show density', + }), + icon: 'logoElasticsearch', + renderWizard: ({ previewLayer }: RenderWizardArguments) => { + const onSourceConfigChange = (sourceConfig: Partial) => { + if (!sourceConfig) { + previewLayer(null); + return; + } + + const layerDescriptor = HeatmapLayer.createDescriptor({ + sourceDescriptor: ESGeoGridSource.createDescriptor(sourceConfig), + }); + previewLayer(layerDescriptor); + }; + + return ( + + ); + }, + title: heatmapTitle, +}; diff --git a/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/index.js b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/index.js index c2fa2356b1a3e..2db66ff411627 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/index.js +++ b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/index.js @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export { - ESGeoGridSource, - clustersLayerWizardConfig, - heatmapLayerWizardConfig, -} from './es_geo_grid_source'; +export { clustersLayerWizardConfig } from './clusters_layer_wizard'; +export { ESGeoGridSource } from './es_geo_grid_source'; +export { heatmapLayerWizardConfig } from './heatmap_layer_wizard'; diff --git a/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/es_pew_pew_source.js b/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/es_pew_pew_source.js index 57e5afb99404b..0d15cff032410 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/es_pew_pew_source.js +++ b/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/es_pew_pew_source.js @@ -8,29 +8,18 @@ import React from 'react'; import uuid from 'uuid/v4'; import { VECTOR_SHAPE_TYPES } from '../vector_feature_types'; -import { VectorLayer } from '../../vector_layer'; -import { CreateSourceEditor } from './create_source_editor'; import { UpdateSourceEditor } from './update_source_editor'; -import { VectorStyle } from '../../styles/vector/vector_style'; -import { getDefaultDynamicProperties } from '../../styles/vector/vector_style_defaults'; import { i18n } from '@kbn/i18n'; -import { - FIELD_ORIGIN, - SOURCE_TYPES, - COUNT_PROP_NAME, - VECTOR_STYLES, -} from '../../../../common/constants'; +import { SOURCE_TYPES } from '../../../../common/constants'; import { getDataSourceLabel } from '../../../../common/i18n_getters'; import { convertToLines } from './convert_to_lines'; import { AbstractESAggSource } from '../es_agg_source'; -import { DynamicStyleProperty } from '../../styles/vector/properties/dynamic_style_property'; -import { COLOR_GRADIENTS } from '../../styles/color_utils'; import { indexPatterns } from '../../../../../../../src/plugins/data/public'; import { registerSource } from '../source_registry'; const MAX_GEOTILE_LEVEL = 29; -const sourceTitle = i18n.translate('xpack.maps.source.pewPewTitle', { +export const sourceTitle = i18n.translate('xpack.maps.source.pewPewTitle', { defaultMessage: 'Point to point', }); @@ -109,43 +98,6 @@ export class ESPewPewSource extends AbstractESAggSource { ]; } - createDefaultLayer(options) { - const defaultDynamicProperties = getDefaultDynamicProperties(); - const styleDescriptor = VectorStyle.createDescriptor({ - [VECTOR_STYLES.LINE_COLOR]: { - type: DynamicStyleProperty.type, - options: { - ...defaultDynamicProperties[VECTOR_STYLES.LINE_COLOR].options, - field: { - name: COUNT_PROP_NAME, - origin: FIELD_ORIGIN.SOURCE, - }, - color: COLOR_GRADIENTS[0].value, - }, - }, - [VECTOR_STYLES.LINE_WIDTH]: { - type: DynamicStyleProperty.type, - options: { - ...defaultDynamicProperties[VECTOR_STYLES.LINE_WIDTH].options, - field: { - name: COUNT_PROP_NAME, - origin: FIELD_ORIGIN.SOURCE, - }, - }, - }, - }); - - return new VectorLayer({ - layerDescriptor: VectorLayer.createDescriptor({ - ...options, - sourceDescriptor: this._descriptor, - style: styleDescriptor, - }), - source: this, - style: new VectorStyle(styleDescriptor, this), - }); - } - getGeoGridPrecision(zoom) { const targetGeotileLevel = Math.ceil(zoom) + 2; return Math.min(targetGeotileLevel, MAX_GEOTILE_LEVEL); @@ -234,25 +186,3 @@ registerSource({ ConstructorFunction: ESPewPewSource, type: SOURCE_TYPES.ES_PEW_PEW, }); - -export const point2PointLayerWizardConfig = { - description: i18n.translate('xpack.maps.source.pewPewDescription', { - defaultMessage: 'Aggregated data paths between the source and destination', - }), - icon: 'logoElasticsearch', - renderWizard: ({ onPreviewSource, inspectorAdapters }) => { - const onSourceConfigChange = sourceConfig => { - if (!sourceConfig) { - onPreviewSource(null); - return; - } - - const sourceDescriptor = ESPewPewSource.createDescriptor(sourceConfig); - const source = new ESPewPewSource(sourceDescriptor, inspectorAdapters); - onPreviewSource(source); - }; - - return ; - }, - title: sourceTitle, -}; diff --git a/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/index.js b/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/index.js new file mode 100644 index 0000000000000..fabde578085ab --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/index.js @@ -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 { ESPewPewSource } from './es_pew_pew_source'; +export { point2PointLayerWizardConfig } from './point_2_point_layer_wizard'; diff --git a/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx b/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx new file mode 100644 index 0000000000000..3ad6d64903d4a --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/point_2_point_layer_wizard.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 from 'react'; +import { i18n } from '@kbn/i18n'; +import { getDefaultDynamicProperties } from '../../styles/vector/vector_style_defaults'; +import { VectorLayer } from '../../vector_layer'; +// @ts-ignore +import { ESPewPewSource, sourceTitle } from './es_pew_pew_source'; +import { VectorStyle } from '../../styles/vector/vector_style'; +import { + FIELD_ORIGIN, + COUNT_PROP_NAME, + VECTOR_STYLES, + STYLE_TYPE, +} from '../../../../common/constants'; +// @ts-ignore +import { COLOR_GRADIENTS } from '../../styles/color_utils'; +// @ts-ignore +import { CreateSourceEditor } from './create_source_editor'; +import { LayerWizard, RenderWizardArguments } from '../../layer_wizard_registry'; +import { ColorDynamicOptions, SizeDynamicOptions } from '../../../../common/descriptor_types'; + +export const point2PointLayerWizardConfig: LayerWizard = { + description: i18n.translate('xpack.maps.source.pewPewDescription', { + defaultMessage: 'Aggregated data paths between the source and destination', + }), + icon: 'logoElasticsearch', + renderWizard: ({ previewLayer }: RenderWizardArguments) => { + const onSourceConfigChange = (sourceConfig: unknown) => { + if (!sourceConfig) { + previewLayer(null); + return; + } + + const defaultDynamicProperties = getDefaultDynamicProperties(); + const layerDescriptor = VectorLayer.createDescriptor({ + sourceDescriptor: ESPewPewSource.createDescriptor(sourceConfig), + style: VectorStyle.createDescriptor({ + [VECTOR_STYLES.LINE_COLOR]: { + type: STYLE_TYPE.DYNAMIC, + options: { + ...(defaultDynamicProperties[VECTOR_STYLES.LINE_COLOR]! + .options as ColorDynamicOptions), + field: { + name: COUNT_PROP_NAME, + origin: FIELD_ORIGIN.SOURCE, + }, + color: COLOR_GRADIENTS[0].value, + }, + }, + [VECTOR_STYLES.LINE_WIDTH]: { + type: STYLE_TYPE.DYNAMIC, + options: { + ...(defaultDynamicProperties[VECTOR_STYLES.LINE_WIDTH]! + .options as SizeDynamicOptions), + field: { + name: COUNT_PROP_NAME, + origin: FIELD_ORIGIN.SOURCE, + }, + }, + }, + }), + }); + previewLayer(layerDescriptor); + }; + + return ; + }, + title: sourceTitle, +}; diff --git a/x-pack/plugins/maps/public/layers/sources/es_search_source/es_documents_layer_wizard.tsx b/x-pack/plugins/maps/public/layers/sources/es_search_source/es_documents_layer_wizard.tsx new file mode 100644 index 0000000000000..4a775dd78f787 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_search_source/es_documents_layer_wizard.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 { i18n } from '@kbn/i18n'; +import React from 'react'; +// @ts-ignore +import { CreateSourceEditor } from './create_source_editor'; +import { LayerWizard, RenderWizardArguments } from '../../layer_wizard_registry'; +// @ts-ignore +import { ESSearchSource, sourceTitle } from './es_search_source'; +import { BlendedVectorLayer } from '../../blended_vector_layer'; +import { VectorLayer } from '../../vector_layer'; +import { SCALING_TYPES } from '../../../../common/constants'; + +export function createDefaultLayerDescriptor(sourceConfig: unknown, mapColors: string[]) { + const sourceDescriptor = ESSearchSource.createDescriptor(sourceConfig); + + return sourceDescriptor.scalingType === SCALING_TYPES.CLUSTERS + ? BlendedVectorLayer.createDescriptor({ sourceDescriptor }, mapColors) + : VectorLayer.createDescriptor({ sourceDescriptor }, mapColors); +} + +export const esDocumentsLayerWizardConfig: LayerWizard = { + description: i18n.translate('xpack.maps.source.esSearchDescription', { + defaultMessage: 'Vector data from a Kibana index pattern', + }), + icon: 'logoElasticsearch', + renderWizard: ({ previewLayer, mapColors }: RenderWizardArguments) => { + const onSourceConfigChange = (sourceConfig: unknown) => { + if (!sourceConfig) { + previewLayer(null); + return; + } + + previewLayer(createDefaultLayerDescriptor(sourceConfig, mapColors)); + }; + return ; + }, + title: sourceTitle, +}; diff --git a/x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.d.ts b/x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.d.ts index c904280a38c85..23e3c759d73c3 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.d.ts +++ b/x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.d.ts @@ -8,6 +8,8 @@ import { AbstractESSource } from '../es_source'; import { ESSearchSourceDescriptor } from '../../../../common/descriptor_types'; export class ESSearchSource extends AbstractESSource { + static createDescriptor(sourceConfig: unknown): ESSearchSourceDescriptor; + constructor(sourceDescriptor: Partial, inspectorAdapters: unknown); getFieldNames(): string[]; } diff --git a/x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.js b/x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.js index bfbcca1eb3f61..a412c49faceac 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.js +++ b/x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.js @@ -6,15 +6,11 @@ import _ from 'lodash'; import React from 'react'; -import uuid from 'uuid/v4'; import { VECTOR_SHAPE_TYPES } from '../vector_feature_types'; import { AbstractESSource } from '../es_source'; import { getSearchService } from '../../../kibana_services'; -import { VectorStyle } from '../../styles/vector/vector_style'; -import { VectorLayer } from '../../vector_layer'; import { hitsToGeoJson } from '../../../elasticsearch_geo_utils'; -import { CreateSourceEditor } from './create_source_editor'; import { UpdateSourceEditor } from './update_source_editor'; import { SOURCE_TYPES, @@ -27,14 +23,14 @@ import { i18n } from '@kbn/i18n'; import { getDataSourceLabel } from '../../../../common/i18n_getters'; import { getSourceFields } from '../../../index_pattern_util'; import { loadIndexSettings } from './load_index_settings'; -import { BlendedVectorLayer } from '../../blended_vector_layer'; +import uuid from 'uuid/v4'; import { DEFAULT_FILTER_BY_MAP_BOUNDS } from './constants'; import { ESDocField } from '../../fields/es_doc_field'; import { getField, addFieldToDSL } from '../../util/es_agg_utils'; import { registerSource } from '../source_registry'; -const sourceTitle = i18n.translate('xpack.maps.source.esSearchTitle', { +export const sourceTitle = i18n.translate('xpack.maps.source.esSearchTitle', { defaultMessage: 'Documents', }); @@ -71,56 +67,31 @@ function getDocValueAndSourceFields(indexPattern, fieldNames) { export class ESSearchSource extends AbstractESSource { static type = SOURCE_TYPES.ES_SEARCH; + static createDescriptor(descriptor) { + return { + ...descriptor, + id: descriptor.id ? descriptor.id : uuid(), + type: ESSearchSource.type, + indexPatternId: descriptor.indexPatternId, + geoField: descriptor.geoField, + filterByMapBounds: _.get(descriptor, 'filterByMapBounds', DEFAULT_FILTER_BY_MAP_BOUNDS), + tooltipProperties: _.get(descriptor, 'tooltipProperties', []), + sortField: _.get(descriptor, 'sortField', ''), + sortOrder: _.get(descriptor, 'sortOrder', SORT_ORDER.DESC), + scalingType: _.get(descriptor, 'scalingType', SCALING_TYPES.LIMIT), + topHitsSplitField: descriptor.topHitsSplitField, + topHitsSize: _.get(descriptor, 'topHitsSize', 1), + }; + } + constructor(descriptor, inspectorAdapters) { - super( - { - ...descriptor, - id: descriptor.id, - type: ESSearchSource.type, - indexPatternId: descriptor.indexPatternId, - geoField: descriptor.geoField, - filterByMapBounds: _.get(descriptor, 'filterByMapBounds', DEFAULT_FILTER_BY_MAP_BOUNDS), - tooltipProperties: _.get(descriptor, 'tooltipProperties', []), - sortField: _.get(descriptor, 'sortField', ''), - sortOrder: _.get(descriptor, 'sortOrder', SORT_ORDER.DESC), - scalingType: _.get(descriptor, 'scalingType', SCALING_TYPES.LIMIT), - topHitsSplitField: descriptor.topHitsSplitField, - topHitsSize: _.get(descriptor, 'topHitsSize', 1), - }, - inspectorAdapters - ); + super(ESSearchSource.createDescriptor(descriptor), inspectorAdapters); this._tooltipFields = this._descriptor.tooltipProperties.map(property => this.createField({ fieldName: property }) ); } - createDefaultLayer(options, mapColors) { - if (this._descriptor.scalingType === SCALING_TYPES.CLUSTERS) { - const layerDescriptor = BlendedVectorLayer.createDescriptor( - { - sourceDescriptor: this._descriptor, - ...options, - }, - mapColors - ); - const style = new VectorStyle(layerDescriptor.style, this); - return new BlendedVectorLayer({ - layerDescriptor: layerDescriptor, - source: this, - style, - }); - } - - const layerDescriptor = this._createDefaultLayerDescriptor(options, mapColors); - const style = new VectorStyle(layerDescriptor.style, this); - return new VectorLayer({ - layerDescriptor: layerDescriptor, - source: this, - style, - }); - } - createField({ fieldName }) { return new ESDocField({ fieldName, @@ -586,29 +557,3 @@ registerSource({ ConstructorFunction: ESSearchSource, type: SOURCE_TYPES.ES_SEARCH, }); - -export const esDocumentsLayerWizardConfig = { - description: i18n.translate('xpack.maps.source.esSearchDescription', { - defaultMessage: 'Vector data from a Kibana index pattern', - }), - icon: 'logoElasticsearch', - renderWizard: ({ onPreviewSource, inspectorAdapters }) => { - const onSourceConfigChange = sourceConfig => { - if (!sourceConfig) { - onPreviewSource(null); - return; - } - - const source = new ESSearchSource( - { - id: uuid(), - ...sourceConfig, - }, - inspectorAdapters - ); - onPreviewSource(source); - }; - return ; - }, - title: sourceTitle, -}; diff --git a/x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.test.ts b/x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.test.ts deleted file mode 100644 index 66cc2ddd85404..0000000000000 --- a/x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.test.ts +++ /dev/null @@ -1,33 +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. - */ -jest.mock('ui/new_platform'); -jest.mock('../../../kibana_services'); - -import { ESSearchSource } from './es_search_source'; -import { VectorLayer } from '../../vector_layer'; -import { SCALING_TYPES, SOURCE_TYPES } from '../../../../common/constants'; -import { ESSearchSourceDescriptor } from '../../../../common/descriptor_types'; - -const descriptor: ESSearchSourceDescriptor = { - type: SOURCE_TYPES.ES_SEARCH, - id: '1234', - indexPatternId: 'myIndexPattern', - geoField: 'myLocation', - scalingType: SCALING_TYPES.LIMIT, -}; - -describe('ES Search Source', () => { - beforeEach(() => { - require('../../../kibana_services').getUiSettings = () => ({ - get: jest.fn(), - }); - }); - it('should create a vector layer', () => { - const source = new ESSearchSource(descriptor, null); - const layer = source.createDefaultLayer(); - expect(layer instanceof VectorLayer).toEqual(true); - }); -}); diff --git a/x-pack/plugins/maps/public/layers/sources/es_search_source/index.js b/x-pack/plugins/maps/public/layers/sources/es_search_source/index.js index 2c401ac92567e..6ae327a18b7c2 100644 --- a/x-pack/plugins/maps/public/layers/sources/es_search_source/index.js +++ b/x-pack/plugins/maps/public/layers/sources/es_search_source/index.js @@ -4,4 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -export { ESSearchSource, esDocumentsLayerWizardConfig } from './es_search_source'; +export { ESSearchSource } from './es_search_source'; +export { + createDefaultLayerDescriptor, + esDocumentsLayerWizardConfig, +} from './es_documents_layer_wizard'; diff --git a/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/index.js b/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/index.js index 00c3bfc5f17c6..6e9d7ad1a613b 100644 --- a/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/index.js +++ b/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/index.js @@ -4,4 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { KibanaRegionmapSource, kibanaRegionMapLayerWizardConfig } from './kibana_regionmap_source'; +export { kibanaRegionMapLayerWizardConfig } from './kibana_regionmap_layer_wizard'; +export { KibanaRegionmapSource } from './kibana_regionmap_source'; diff --git a/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/kibana_regionmap_layer_wizard.tsx b/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/kibana_regionmap_layer_wizard.tsx new file mode 100644 index 0000000000000..4321501760faf --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/kibana_regionmap_layer_wizard.tsx @@ -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 React from 'react'; +import { i18n } from '@kbn/i18n'; +import { LayerWizard, RenderWizardArguments } from '../../layer_wizard_registry'; +// @ts-ignore +import { KibanaRegionmapSource, sourceTitle } from './kibana_regionmap_source'; +import { VectorLayer } from '../../vector_layer'; +// @ts-ignore +import { CreateSourceEditor } from './create_source_editor'; + +export const kibanaRegionMapLayerWizardConfig: LayerWizard = { + description: i18n.translate('xpack.maps.source.kbnRegionMapDescription', { + defaultMessage: 'Vector data from hosted GeoJSON configured in kibana.yml', + }), + icon: 'logoKibana', + renderWizard: ({ previewLayer, mapColors }: RenderWizardArguments) => { + const onSourceConfigChange = (sourceConfig: unknown) => { + const sourceDescriptor = KibanaRegionmapSource.createDescriptor(sourceConfig); + const layerDescriptor = VectorLayer.createDescriptor({ sourceDescriptor }, mapColors); + previewLayer(layerDescriptor); + }; + + return ; + }, + title: sourceTitle, +}; diff --git a/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/kibana_regionmap_source.js b/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/kibana_regionmap_source.js index be333f8ee85a4..fb5a2e4f42f1d 100644 --- a/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/kibana_regionmap_source.js +++ b/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/kibana_regionmap_source.js @@ -5,8 +5,6 @@ */ import { AbstractVectorSource } from '../vector_source'; -import React from 'react'; -import { CreateSourceEditor } from './create_source_editor'; import { getKibanaRegionList } from '../../../meta'; import { i18n } from '@kbn/i18n'; import { getDataSourceLabel } from '../../../../common/i18n_getters'; @@ -14,7 +12,7 @@ import { FIELD_ORIGIN, SOURCE_TYPES } from '../../../../common/constants'; import { KibanaRegionField } from '../../fields/kibana_region_field'; import { registerSource } from '../source_registry'; -const sourceTitle = i18n.translate('xpack.maps.source.kbnRegionMapTitle', { +export const sourceTitle = i18n.translate('xpack.maps.source.kbnRegionMapTitle', { defaultMessage: 'Configured GeoJSON', }); @@ -101,20 +99,3 @@ registerSource({ ConstructorFunction: KibanaRegionmapSource, type: SOURCE_TYPES.REGIONMAP_FILE, }); - -export const kibanaRegionMapLayerWizardConfig = { - description: i18n.translate('xpack.maps.source.kbnRegionMapDescription', { - defaultMessage: 'Vector data from hosted GeoJSON configured in kibana.yml', - }), - icon: 'logoKibana', - renderWizard: ({ onPreviewSource, inspectorAdapters }) => { - const onSourceConfigChange = sourceConfig => { - const sourceDescriptor = KibanaRegionmapSource.createDescriptor(sourceConfig); - const source = new KibanaRegionmapSource(sourceDescriptor, inspectorAdapters); - onPreviewSource(source); - }; - - return ; - }, - title: sourceTitle, -}; diff --git a/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/index.js b/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/index.js index 9fd7f088032ca..cc89091605456 100644 --- a/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/index.js +++ b/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/index.js @@ -4,4 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { KibanaTilemapSource, kibanaBasemapLayerWizardConfig } from './kibana_tilemap_source'; +export { kibanaBasemapLayerWizardConfig } from './kibana_base_map_layer_wizard'; +export { KibanaTilemapSource } from './kibana_tilemap_source'; diff --git a/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx b/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx new file mode 100644 index 0000000000000..aeea2d6084f84 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx @@ -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 React from 'react'; +import { i18n } from '@kbn/i18n'; +import { LayerWizard, RenderWizardArguments } from '../../layer_wizard_registry'; +// @ts-ignore +import { CreateSourceEditor } from './create_source_editor'; +// @ts-ignore +import { KibanaTilemapSource, sourceTitle } from './kibana_tilemap_source'; +import { TileLayer } from '../../tile_layer'; + +export const kibanaBasemapLayerWizardConfig: LayerWizard = { + description: i18n.translate('xpack.maps.source.kbnTMSDescription', { + defaultMessage: 'Tile map service configured in kibana.yml', + }), + icon: 'logoKibana', + renderWizard: ({ previewLayer }: RenderWizardArguments) => { + const onSourceConfigChange = () => { + const layerDescriptor = TileLayer.createDescriptor({ + sourceDescriptor: KibanaTilemapSource.createDescriptor(), + }); + previewLayer(layerDescriptor); + }; + return ; + }, + title: sourceTitle, +}; diff --git a/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/kibana_tilemap_source.js b/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/kibana_tilemap_source.js index bbb653eff32e2..7dc1d454a1c52 100644 --- a/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/kibana_tilemap_source.js +++ b/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/kibana_tilemap_source.js @@ -3,10 +3,8 @@ * 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 { AbstractTMSSource } from '../tms_source'; -import { TileLayer } from '../../tile_layer'; -import { CreateSourceEditor } from './create_source_editor'; import { getKibanaTileMap } from '../../../meta'; import { i18n } from '@kbn/i18n'; import { getDataSourceLabel } from '../../../../common/i18n_getters'; @@ -14,7 +12,7 @@ import _ from 'lodash'; import { SOURCE_TYPES } from '../../../../common/constants'; import { registerSource } from '../source_registry'; -const sourceTitle = i18n.translate('xpack.maps.source.kbnTMSTitle', { +export const sourceTitle = i18n.translate('xpack.maps.source.kbnTMSTitle', { defaultMessage: 'Configured Tile Map Service', }); @@ -42,20 +40,6 @@ export class KibanaTilemapSource extends AbstractTMSSource { ]; } - _createDefaultLayerDescriptor(options) { - return TileLayer.createDescriptor({ - sourceDescriptor: this._descriptor, - ...options, - }); - } - - createDefaultLayer(options) { - return new TileLayer({ - layerDescriptor: this._createDefaultLayerDescriptor(options), - source: this, - }); - } - async getUrlTemplate() { const tilemap = getKibanaTileMap(); if (!tilemap.url) { @@ -88,19 +72,3 @@ registerSource({ ConstructorFunction: KibanaTilemapSource, type: SOURCE_TYPES.KIBANA_TILEMAP, }); - -export const kibanaBasemapLayerWizardConfig = { - description: i18n.translate('xpack.maps.source.kbnTMSDescription', { - defaultMessage: 'Tile map service configured in kibana.yml', - }), - icon: 'logoKibana', - renderWizard: ({ onPreviewSource, inspectorAdapters }) => { - const onSourceConfigChange = () => { - const sourceDescriptor = KibanaTilemapSource.createDescriptor(); - const source = new KibanaTilemapSource(sourceDescriptor, inspectorAdapters); - onPreviewSource(source); - }; - return ; - }, - title: sourceTitle, -}; diff --git a/x-pack/plugins/maps/public/layers/sources/mvt_single_layer_vector_source/layer_wizard.tsx b/x-pack/plugins/maps/public/layers/sources/mvt_single_layer_vector_source/layer_wizard.tsx index dfdea1489d50c..c94fec3deac67 100644 --- a/x-pack/plugins/maps/public/layers/sources/mvt_single_layer_vector_source/layer_wizard.tsx +++ b/x-pack/plugins/maps/public/layers/sources/mvt_single_layer_vector_source/layer_wizard.tsx @@ -12,30 +12,20 @@ import { } from './mvt_single_layer_vector_source_editor'; import { MVTSingleLayerVectorSource, sourceTitle } from './mvt_single_layer_vector_source'; import { LayerWizard, RenderWizardArguments } from '../../layer_wizard_registry'; -import { SOURCE_TYPES } from '../../../../common/constants'; +import { TiledVectorLayer } from '../../tiled_vector_layer'; export const mvtVectorSourceWizardConfig: LayerWizard = { description: i18n.translate('xpack.maps.source.mvtVectorSourceWizard', { defaultMessage: 'Vector source wizard', }), icon: 'grid', - renderWizard: ({ onPreviewSource, inspectorAdapters }: RenderWizardArguments) => { - const onSourceConfigChange = ({ - urlTemplate, - layerName, - minSourceZoom, - maxSourceZoom, - }: MVTSingleLayerVectorSourceConfig) => { - const sourceDescriptor = MVTSingleLayerVectorSource.createDescriptor({ - urlTemplate, - layerName, - minSourceZoom, - maxSourceZoom, - type: SOURCE_TYPES.MVT_SINGLE_LAYER, - }); - const source = new MVTSingleLayerVectorSource(sourceDescriptor, inspectorAdapters); - onPreviewSource(source); + renderWizard: ({ previewLayer, mapColors }: RenderWizardArguments) => { + const onSourceConfigChange = (sourceConfig: MVTSingleLayerVectorSourceConfig) => { + const sourceDescriptor = MVTSingleLayerVectorSource.createDescriptor(sourceConfig); + const layerDescriptor = TiledVectorLayer.createDescriptor({ sourceDescriptor }, mapColors); + previewLayer(layerDescriptor); }; + return ; }, title: sourceTitle, diff --git a/x-pack/plugins/maps/public/layers/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.ts b/x-pack/plugins/maps/public/layers/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.ts index a73cfbdc0d043..58e6e39aaa1f9 100644 --- a/x-pack/plugins/maps/public/layers/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.ts +++ b/x-pack/plugins/maps/public/layers/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.ts @@ -7,7 +7,6 @@ import { i18n } from '@kbn/i18n'; import uuid from 'uuid/v4'; import { AbstractSource, ImmutableSourceProperty } from '../source'; -import { TiledVectorLayer } from '../../tiled_vector_layer'; import { GeoJsonWithMeta, ITiledSingleLayerVectorSource } from '../vector_source'; import { MAX_ZOOM, MIN_ZOOM, SOURCE_TYPES } from '../../../../common/constants'; import { VECTOR_SHAPE_TYPES } from '../vector_feature_types'; @@ -17,11 +16,10 @@ import { getDataSourceLabel, getUrlLabel } from '../../../../common/i18n_getters import { MapExtent, TiledSingleLayerVectorSourceDescriptor, - VectorLayerDescriptor, VectorSourceRequestMeta, VectorSourceSyncMeta, } from '../../../../common/descriptor_types'; -import { VectorLayerArguments } from '../../vector_layer'; +import { MVTSingleLayerVectorSourceConfig } from './mvt_single_layer_vector_source_editor'; export const sourceTitle = i18n.translate( 'xpack.maps.source.MVTSingleLayerVectorSource.sourceTitle', @@ -37,7 +35,7 @@ export class MVTSingleLayerVectorSource extends AbstractSource layerName, minSourceZoom, maxSourceZoom, - }: TiledSingleLayerVectorSourceDescriptor) { + }: MVTSingleLayerVectorSourceConfig) { return { type: SOURCE_TYPES.MVT_SINGLE_LAYER, id: uuid(), @@ -66,22 +64,6 @@ export class MVTSingleLayerVectorSource extends AbstractSource return []; } - createDefaultLayer(options?: Partial): TiledVectorLayer { - const layerDescriptor: Partial = { - sourceDescriptor: this._descriptor, - ...options, - }; - const normalizedLayerDescriptor: VectorLayerDescriptor = TiledVectorLayer.createDescriptor( - layerDescriptor, - [] - ); - const vectorLayerArguments: VectorLayerArguments = { - layerDescriptor: normalizedLayerDescriptor, - source: this, - }; - return new TiledVectorLayer(vectorLayerArguments); - } - getGeoJsonWithMeta( layerName: 'string', searchFilters: unknown[], diff --git a/x-pack/plugins/maps/public/layers/sources/source.ts b/x-pack/plugins/maps/public/layers/sources/source.ts index 1cd84010159ab..af934d7464f61 100644 --- a/x-pack/plugins/maps/public/layers/sources/source.ts +++ b/x-pack/plugins/maps/public/layers/sources/source.ts @@ -13,8 +13,7 @@ import { Adapters } from 'src/plugins/inspector/public'; // @ts-ignore import { copyPersistentState } from '../../reducers/util'; -import { LayerDescriptor, SourceDescriptor } from '../../../common/descriptor_types'; -import { ILayer } from '../layer'; +import { SourceDescriptor } from '../../../common/descriptor_types'; import { IField } from '../fields/field'; import { MAX_ZOOM, MIN_ZOOM } from '../../../common/constants'; @@ -37,7 +36,6 @@ export type PreIndexedShape = { export type FieldFormatter = (value: string | number | null | undefined | boolean) => string; export interface ISource { - createDefaultLayer(options?: Partial): ILayer; destroy(): void; getDisplayName(): Promise; getInspectorAdapters(): Adapters | undefined; @@ -59,7 +57,6 @@ export interface ISource { getIndexPatternIds(): string[]; getQueryableIndexPatternIds(): string[]; getGeoGridPrecision(zoom: number): number; - shouldBeIndexed(): boolean; getPreIndexedShape(): Promise; createFieldFormatter(field: IField): Promise; getValueSuggestions(field: IField, query: string): Promise; @@ -99,10 +96,6 @@ export class AbstractSource implements ISource { return this._inspectorAdapters; } - createDefaultLayer(options?: Partial): ILayer { - throw new Error(`Source#createDefaultLayer not implemented`); - } - async getDisplayName(): Promise { return ''; } @@ -155,10 +148,6 @@ export class AbstractSource implements ISource { return false; } - shouldBeIndexed(): boolean { - return false; - } - isESSource(): boolean { return false; } diff --git a/x-pack/plugins/maps/public/layers/sources/vector_source/vector_source.js b/x-pack/plugins/maps/public/layers/sources/vector_source/vector_source.js index 509584cbc415a..12e1780d9cad5 100644 --- a/x-pack/plugins/maps/public/layers/sources/vector_source/vector_source.js +++ b/x-pack/plugins/maps/public/layers/sources/vector_source/vector_source.js @@ -4,9 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { VectorLayer } from '../../vector_layer'; import { TooltipProperty } from '../../tooltips/tooltip_property'; -import { VectorStyle } from '../../styles/vector/vector_style'; import { AbstractSource } from './../source'; import * as topojson from 'topojson-client'; import _ from 'lodash'; @@ -74,30 +72,10 @@ export class AbstractVectorSource extends AbstractSource { return this.createField({ fieldName: name }); } - _createDefaultLayerDescriptor(options, mapColors) { - return VectorLayer.createDescriptor( - { - sourceDescriptor: this._descriptor, - ...options, - }, - mapColors - ); - } - _getTooltipPropertyNames() { return this._tooltipFields.map(field => field.getName()); } - createDefaultLayer(options, mapColors) { - const layerDescriptor = this._createDefaultLayerDescriptor(options, mapColors); - const style = new VectorStyle(layerDescriptor.style, this); - return new VectorLayer({ - layerDescriptor: layerDescriptor, - source: this, - style, - }); - } - isFilterByMapBounds() { return false; } diff --git a/x-pack/plugins/maps/public/layers/sources/wms_source/index.js b/x-pack/plugins/maps/public/layers/sources/wms_source/index.js index daae552a6f772..792e7e862826c 100644 --- a/x-pack/plugins/maps/public/layers/sources/wms_source/index.js +++ b/x-pack/plugins/maps/public/layers/sources/wms_source/index.js @@ -4,4 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { WMSSource, wmsLayerWizardConfig } from './wms_source'; +export { wmsLayerWizardConfig } from './wms_layer_wizard'; +export { WMSSource } from './wms_source'; diff --git a/x-pack/plugins/maps/public/layers/sources/wms_source/wms_layer_wizard.tsx b/x-pack/plugins/maps/public/layers/sources/wms_source/wms_layer_wizard.tsx new file mode 100644 index 0000000000000..fbf5e25c78b17 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/wms_source/wms_layer_wizard.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 { i18n } from '@kbn/i18n'; +// @ts-ignore +import { WMSCreateSourceEditor } from './wms_create_source_editor'; +// @ts-ignore +import { sourceTitle, WMSSource } from './wms_source'; +import { LayerWizard, RenderWizardArguments } from '../../layer_wizard_registry'; +import { TileLayer } from '../../tile_layer'; + +export const wmsLayerWizardConfig: LayerWizard = { + description: i18n.translate('xpack.maps.source.wmsDescription', { + defaultMessage: 'Maps from OGC Standard WMS', + }), + icon: 'grid', + renderWizard: ({ previewLayer }: RenderWizardArguments) => { + const onSourceConfigChange = (sourceConfig: unknown) => { + if (!sourceConfig) { + previewLayer(null); + return; + } + + const layerDescriptor = TileLayer.createDescriptor({ + sourceDescriptor: WMSSource.createDescriptor(sourceConfig), + }); + previewLayer(layerDescriptor); + }; + return ; + }, + title: sourceTitle, +}; diff --git a/x-pack/plugins/maps/public/layers/sources/wms_source/wms_source.js b/x-pack/plugins/maps/public/layers/sources/wms_source/wms_source.js index 33f764784124e..cb8f9c34e2b57 100644 --- a/x-pack/plugins/maps/public/layers/sources/wms_source/wms_source.js +++ b/x-pack/plugins/maps/public/layers/sources/wms_source/wms_source.js @@ -4,18 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; - import { AbstractTMSSource } from '../tms_source'; -import { TileLayer } from '../../tile_layer'; -import { WMSCreateSourceEditor } from './wms_create_source_editor'; import { i18n } from '@kbn/i18n'; import { getDataSourceLabel, getUrlLabel } from '../../../../common/i18n_getters'; import { WmsClient } from './wms_client'; import { SOURCE_TYPES } from '../../../../common/constants'; import { registerSource } from '../source_registry'; -const sourceTitle = i18n.translate('xpack.maps.source.wmsTitle', { +export const sourceTitle = i18n.translate('xpack.maps.source.wmsTitle', { defaultMessage: 'Web Map Service', }); @@ -52,20 +48,6 @@ export class WMSSource extends AbstractTMSSource { ]; } - _createDefaultLayerDescriptor(options) { - return TileLayer.createDescriptor({ - sourceDescriptor: this._descriptor, - ...options, - }); - } - - createDefaultLayer(options) { - return new TileLayer({ - layerDescriptor: this._createDefaultLayerDescriptor(options), - source: this, - }); - } - async getDisplayName() { return this._descriptor.serviceUrl; } @@ -94,24 +76,3 @@ registerSource({ ConstructorFunction: WMSSource, type: SOURCE_TYPES.WMS, }); - -export const wmsLayerWizardConfig = { - description: i18n.translate('xpack.maps.source.wmsDescription', { - defaultMessage: 'Maps from OGC Standard WMS', - }), - icon: 'grid', - renderWizard: ({ onPreviewSource, inspectorAdapters }) => { - const onSourceConfigChange = sourceConfig => { - if (!sourceConfig) { - onPreviewSource(null); - return; - } - - const sourceDescriptor = WMSSource.createDescriptor(sourceConfig); - const source = new WMSSource(sourceDescriptor, inspectorAdapters); - onPreviewSource(source); - }; - return ; - }, - title: sourceTitle, -}; diff --git a/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/layer_wizard.tsx b/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/layer_wizard.tsx index 8b1ed588c8dd1..e970c75fa7adf 100644 --- a/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/layer_wizard.tsx +++ b/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/layer_wizard.tsx @@ -9,17 +9,19 @@ import React from 'react'; import { XYZTMSEditor, XYZTMSSourceConfig } from './xyz_tms_editor'; import { XYZTMSSource, sourceTitle } from './xyz_tms_source'; import { LayerWizard, RenderWizardArguments } from '../../layer_wizard_registry'; +import { TileLayer } from '../../tile_layer'; export const tmsLayerWizardConfig: LayerWizard = { description: i18n.translate('xpack.maps.source.ems_xyzDescription', { defaultMessage: 'Tile map service configured in interface', }), icon: 'grid', - renderWizard: ({ onPreviewSource }: RenderWizardArguments) => { + renderWizard: ({ previewLayer }: RenderWizardArguments) => { const onSourceConfigChange = (sourceConfig: XYZTMSSourceConfig) => { - const sourceDescriptor = XYZTMSSource.createDescriptor(sourceConfig); - const source = new XYZTMSSource(sourceDescriptor); - onPreviewSource(source); + const layerDescriptor = TileLayer.createDescriptor({ + sourceDescriptor: XYZTMSSource.createDescriptor(sourceConfig), + }); + previewLayer(layerDescriptor); }; return ; }, diff --git a/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/xyz_tms_source.test.ts b/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/xyz_tms_source.test.ts index 4031a18bff7cb..b1ba6fc6f6f8e 100644 --- a/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/xyz_tms_source.test.ts +++ b/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/xyz_tms_source.test.ts @@ -5,8 +5,6 @@ */ import { XYZTMSSource } from './xyz_tms_source'; -import { ILayer } from '../../layer'; -import { TileLayer } from '../../tile_layer'; import { SOURCE_TYPES } from '../../../../common/constants'; import { XYZTMSSourceDescriptor } from '../../../../common/descriptor_types'; @@ -16,12 +14,6 @@ const descriptor: XYZTMSSourceDescriptor = { id: 'foobar', }; describe('xyz Tilemap Source', () => { - it('should create a tile-layer', () => { - const source = new XYZTMSSource(descriptor); - const layer: ILayer = source.createDefaultLayer(); - expect(layer instanceof TileLayer).toEqual(true); - }); - it('should echo url template for url template', async () => { const source = new XYZTMSSource(descriptor); const template = await source.getUrlTemplate(); diff --git a/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/xyz_tms_source.ts b/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/xyz_tms_source.ts index 77f8d88a8c0ab..b1003d25fb759 100644 --- a/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/xyz_tms_source.ts +++ b/x-pack/plugins/maps/public/layers/sources/xyz_tms_source/xyz_tms_source.ts @@ -5,15 +5,13 @@ */ import { i18n } from '@kbn/i18n'; -import { TileLayer } from '../../tile_layer'; import { getDataSourceLabel, getUrlLabel } from '../../../../common/i18n_getters'; import { SOURCE_TYPES } from '../../../../common/constants'; import { registerSource } from '../source_registry'; import { AbstractTMSSource } from '../tms_source'; -import { LayerDescriptor, XYZTMSSourceDescriptor } from '../../../../common/descriptor_types'; +import { XYZTMSSourceDescriptor } from '../../../../common/descriptor_types'; import { Attribution, ImmutableSourceProperty } from '../source'; import { XYZTMSSourceConfig } from './xyz_tms_editor'; -import { ILayer } from '../../layer'; export const sourceTitle = i18n.translate('xpack.maps.source.ems_xyzTitle', { defaultMessage: 'Tile Map Service', @@ -49,17 +47,6 @@ export class XYZTMSSource extends AbstractTMSSource { ]; } - createDefaultLayer(options?: LayerDescriptor): ILayer { - const layerDescriptor: LayerDescriptor = TileLayer.createDescriptor({ - sourceDescriptor: this._descriptor, - ...options, - }); - return new TileLayer({ - layerDescriptor, - source: this, - }); - } - async getDisplayName(): Promise { return this._descriptor.urlTemplate; } diff --git a/x-pack/plugins/maps/public/layers/tile_layer.test.ts b/x-pack/plugins/maps/public/layers/tile_layer.test.ts index a7e8be9fc4b46..d536b18af4aad 100644 --- a/x-pack/plugins/maps/public/layers/tile_layer.test.ts +++ b/x-pack/plugins/maps/public/layers/tile_layer.test.ts @@ -23,9 +23,6 @@ class MockTileSource extends AbstractTMSSource implements ITMSSource { super(descriptor, {}); this._descriptor = descriptor; } - createDefaultLayer(): ILayer { - throw new Error('not implemented'); - } async getDisplayName(): Promise { return this._descriptor.urlTemplate; diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts index 475e44af3669c..c65d872212ad6 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts @@ -15,7 +15,7 @@ import { LicenseManagementUIPluginSetup } from '../../../../../license_managemen interface StartPlugins { data: DataPublicPluginStart; - security: SecurityPluginSetup; + security?: SecurityPluginSetup; licenseManagement?: LicenseManagementUIPluginSetup; } export type StartServices = CoreStart & StartPlugins; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/filebeat_config_flyout/filebeat_config_flyout.tsx b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/filebeat_config_flyout/filebeat_config_flyout.tsx index 30fc74acbabf4..32b51c8b7d4ee 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/filebeat_config_flyout/filebeat_config_flyout.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/filebeat_config_flyout/filebeat_config_flyout.tsx @@ -55,9 +55,11 @@ export const FilebeatConfigFlyout: FC = ({ } = useMlKibana(); useEffect(() => { - security.authc.getCurrentUser().then(user => { - setUsername(user.username === undefined ? null : user.username); - }); + if (security !== undefined) { + security.authc.getCurrentUser().then(user => { + setUsername(user.username === undefined ? null : user.username); + }); + } }, []); useEffect(() => { diff --git a/x-pack/plugins/ml/public/application/management/index.ts b/x-pack/plugins/ml/public/application/management/index.ts index a6fe9e1d11953..6bc5c9b15074f 100644 --- a/x-pack/plugins/ml/public/application/management/index.ts +++ b/x-pack/plugins/ml/public/application/management/index.ts @@ -25,8 +25,11 @@ export function initManagementSection( ) { const licensing = pluginsSetup.licensing.license$.pipe(take(1)); licensing.subscribe(license => { - if (license.check(PLUGIN_ID, MINIMUM_FULL_LICENSE).state === 'valid') { - const management = pluginsSetup.management; + const management = pluginsSetup.management; + if ( + management !== undefined && + license.check(PLUGIN_ID, MINIMUM_FULL_LICENSE).state === 'valid' + ) { const mlSection = management.sections.register({ id: PLUGIN_ID, title: i18n.translate('xpack.ml.management.mlTitle', { diff --git a/x-pack/plugins/ml/public/application/util/dependency_cache.ts b/x-pack/plugins/ml/public/application/util/dependency_cache.ts index 934a0a5e9ae3a..f8dd2a37dd589 100644 --- a/x-pack/plugins/ml/public/application/util/dependency_cache.ts +++ b/x-pack/plugins/ml/public/application/util/dependency_cache.ts @@ -40,7 +40,7 @@ export interface DependencyCache { savedObjectsClient: SavedObjectsClientContract | null; application: ApplicationStart | null; http: HttpStart | null; - security: SecurityPluginSetup | null; + security: SecurityPluginSetup | undefined | null; i18n: I18nStart | null; urlGenerators: SharePluginStart['urlGenerators'] | null; mlConfig: MlConfigType | null; diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index b51be4d248683..e3b8441db432e 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -32,9 +32,9 @@ export interface MlStartDependencies { share: SharePluginStart; } export interface MlSetupDependencies { - security: SecurityPluginSetup; + security?: SecurityPluginSetup; licensing: LicensingPluginSetup; - management: ManagementSetup; + management?: ManagementSetup; usageCollection: UsageCollectionSetup; licenseManagement?: LicenseManagementUIPluginSetup; home: HomePublicPluginSetup; diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rule_actions/saved_object_mappings.ts b/x-pack/plugins/siem/server/lib/detection_engine/rule_actions/saved_object_mappings.ts index d50c339c95266..e50f82bb482a7 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rule_actions/saved_object_mappings.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/rule_actions/saved_object_mappings.ts @@ -31,9 +31,8 @@ export const ruleActionsSavedObjectMappings = { type: 'keyword', }, params: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - dynamic: true as any, - properties: {}, + type: 'object', + enabled: false, }, }, }, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index c805ce0d186ae..09247031f9a24 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -8819,10 +8819,8 @@ "xpack.logstash.workersTooltip": "パイプラインのフィルターとアウトプットステージを同時に実行するワーカーの数です。イベントが詰まってしまう場合や CPU が飽和状態ではない場合は、マシンの処理能力をより有効に活用するため、この数字を上げてみてください。\n\nデフォルト値:ホストの CPU コア数です", "xpack.maps.addLayerPanel.addLayer": "レイヤーを追加", "xpack.maps.addLayerPanel.changeDataSourceButtonLabel": "データソースを変更", - "xpack.maps.addLayerPanel.chooseDataSourceTitle": "データソースの選択", "xpack.maps.addLayerPanel.footer.cancelButtonLabel": "キャンセル", "xpack.maps.addLayerPanel.importFile": "ファイルのインポート", - "xpack.maps.addLayerPanel.selectSource": "ソースを選択", "xpack.maps.aggs.defaultCountLabel": "カウント", "xpack.maps.appDescription": "マップアプリケーション", "xpack.maps.appTitle": "マップ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index a3ac6c8003ef7..c1aef4d80ceca 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -8822,10 +8822,8 @@ "xpack.logstash.workersTooltip": "并行执行管道的筛选和输出阶段的工作线程数目。如果您发现事件出现积压或 CPU 未饱和,请考虑增大此数值,以更好地利用机器处理能力。\n\n默认值:主机的 CPU 核心数", "xpack.maps.addLayerPanel.addLayer": "添加图层", "xpack.maps.addLayerPanel.changeDataSourceButtonLabel": "更改数据源", - "xpack.maps.addLayerPanel.chooseDataSourceTitle": "选择数据源", "xpack.maps.addLayerPanel.footer.cancelButtonLabel": "鍙栨秷", "xpack.maps.addLayerPanel.importFile": "导入文件", - "xpack.maps.addLayerPanel.selectSource": "选择源", "xpack.maps.aggs.defaultCountLabel": "计数", "xpack.maps.appDescription": "地图应用程序", "xpack.maps.appTitle": "Maps", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx index 9da4f059f8967..230b896eeca7d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx @@ -7,24 +7,55 @@ import * as React from 'react'; import uuid from 'uuid'; import { shallow } from 'enzyme'; import { AlertDetails } from './alert_details'; -import { Alert, ActionType } from '../../../../types'; -import { EuiTitle, EuiBadge, EuiFlexItem, EuiSwitch, EuiBetaBadge } from '@elastic/eui'; +import { Alert, ActionType, AlertTypeRegistryContract } from '../../../../types'; +import { + EuiTitle, + EuiBadge, + EuiFlexItem, + EuiSwitch, + EuiBetaBadge, + EuiButtonEmpty, +} from '@elastic/eui'; import { times, random } from 'lodash'; import { i18n } from '@kbn/i18n'; import { ViewInApp } from './view_in_app'; import { PLUGIN } from '../../../constants/plugin'; +import { coreMock } from 'src/core/public/mocks'; +const mockes = coreMock.createSetup(); jest.mock('../../../app_context', () => ({ useAppDependencies: jest.fn(() => ({ http: jest.fn(), - legacy: { - capabilities: { - get: jest.fn(() => ({})), - }, + capabilities: { + get: jest.fn(() => ({})), }, + actionTypeRegistry: jest.fn(), + alertTypeRegistry: jest.fn(() => { + const mocked: jest.Mocked = { + has: jest.fn(), + register: jest.fn(), + get: jest.fn(), + list: jest.fn(), + }; + return mocked; + }), + toastNotifications: mockes.notifications.toasts, + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, + uiSettings: mockes.uiSettings, + dataPlugin: jest.fn(), + charts: jest.fn(), })), })); +jest.mock('react-router-dom', () => ({ + useHistory: () => ({ + push: jest.fn(), + }), + useLocation: () => ({ + pathname: '/triggersActions/alerts/', + }), +})); + jest.mock('../../../lib/capabilities', () => ({ hasSaveAlertsCapability: jest.fn(() => true), })); @@ -232,6 +263,28 @@ describe('alert_details', () => { ).containsMatchingElement() ).toBeTruthy(); }); + + it('links to the Edit flyout', () => { + const alert = mockAlert(); + + const alertType = { + id: '.noop', + name: 'No Op', + actionGroups: [{ id: 'default', name: 'Default' }], + actionVariables: { context: [], state: [] }, + defaultActionGroupId: 'default', + }; + + expect( + shallow( + + ) + .find(EuiButtonEmpty) + .find('[data-test-subj="openEditAlertFlyoutButton"]') + .first() + .exists() + ).toBeTruthy(); + }); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx index 5bfcf9fd2d4e6..318dd28d92da1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState } from 'react'; +import React, { useState, Fragment } from 'react'; import { indexBy } from 'lodash'; +import { useHistory } from 'react-router-dom'; import { EuiPageBody, EuiPageContent, @@ -21,6 +22,7 @@ import { EuiCallOut, EuiSpacer, EuiBetaBadge, + EuiButtonEmpty, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -34,6 +36,9 @@ import { import { AlertInstancesRouteWithApi } from './alert_instances_route'; import { ViewInApp } from './view_in_app'; import { PLUGIN } from '../../../constants/plugin'; +import { AlertEdit } from '../../alert_form'; +import { AlertsContextProvider } from '../../../context/alerts_context'; +import { routeToAlertDetails } from '../../../constants'; type AlertDetailsProps = { alert: Alert; @@ -52,7 +57,18 @@ export const AlertDetails: React.FunctionComponent = ({ muteAlert, requestRefresh, }) => { - const { capabilities } = useAppDependencies(); + const history = useHistory(); + const { + http, + toastNotifications, + capabilities, + alertTypeRegistry, + actionTypeRegistry, + uiSettings, + docLinks, + charts, + dataPlugin, + } = useAppDependencies(); const canSave = hasSaveAlertsCapability(capabilities); @@ -61,6 +77,11 @@ export const AlertDetails: React.FunctionComponent = ({ const [isEnabled, setIsEnabled] = useState(alert.enabled); const [isMuted, setIsMuted] = useState(alert.muteAll); + const [editFlyoutVisible, setEditFlyoutVisibility] = useState(false); + + const setAlert = async () => { + history.push(routeToAlertDetails.replace(`:alertId`, alert.id)); + }; return ( @@ -90,6 +111,42 @@ export const AlertDetails: React.FunctionComponent = ({ + {canSave ? ( + + + {' '} + setEditFlyoutVisibility(true)} + > + + + + + + + + ) : null} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx index 498ecffe9b947..b9d08abae1684 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx @@ -26,13 +26,13 @@ export const AlertInstancesRoute: React.FunctionComponent = requestRefresh, loadAlertState, }) => { - const { http, toastNotifications } = useAppDependencies(); + const { toastNotifications } = useAppDependencies(); const [alertState, setAlertState] = useState(null); useEffect(() => { getAlertState(alert.id, loadAlertState, setAlertState, toastNotifications); - }, [alert, http, loadAlertState, toastNotifications]); + }, [alert, loadAlertState, toastNotifications]); return alertState ? ( diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts index fe0f630830a56..43d533ad3ae14 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts @@ -14,6 +14,7 @@ export default function(kibana: any) { require: ['xpack_main', 'actions', 'alerting', 'elasticsearch'], name: 'alerts', init(server: any) { + const clusterClient = server.newPlatform.start.core.elasticsearch.legacy.client; server.plugins.xpack_main.registerFeature({ id: 'alerting', name: 'Alerting', @@ -165,6 +166,22 @@ export default function(kibana: any) { } catch (e) { callClusterError = e; } + // Call scoped cluster + const callScopedCluster = services.getScopedCallCluster(clusterClient); + let callScopedClusterSuccess = false; + let callScopedClusterError; + try { + await callScopedCluster('index', { + index: params.callClusterAuthorizationIndex, + refresh: 'wait_for', + body: { + param1: 'test', + }, + }); + callScopedClusterSuccess = true; + } catch (e) { + callScopedClusterError = e; + } // Saved objects client let savedObjectsClientSuccess = false; let savedObjectsClientError; @@ -185,6 +202,8 @@ export default function(kibana: any) { state: { callClusterSuccess, callClusterError, + callScopedClusterSuccess, + callScopedClusterError, savedObjectsClientSuccess, savedObjectsClientError, }, @@ -376,6 +395,22 @@ export default function(kibana: any) { } catch (e) { callClusterError = e; } + // Call scoped cluster + const callScopedCluster = services.getScopedCallCluster(clusterClient); + let callScopedClusterSuccess = false; + let callScopedClusterError; + try { + await callScopedCluster('index', { + index: params.callClusterAuthorizationIndex, + refresh: 'wait_for', + body: { + param1: 'test', + }, + }); + callScopedClusterSuccess = true; + } catch (e) { + callScopedClusterError = e; + } // Saved objects client let savedObjectsClientSuccess = false; let savedObjectsClientError; @@ -396,6 +431,8 @@ export default function(kibana: any) { state: { callClusterSuccess, callClusterError, + callScopedClusterSuccess, + callScopedClusterError, savedObjectsClientSuccess, savedObjectsClientError, }, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts index a58e14dd563ef..af8af72d458fd 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts @@ -436,11 +436,16 @@ export default function({ getService }: FtrProviderContext) { indexedRecord = searchResult.hits.hits[0]; expect(indexedRecord._source.state).to.eql({ callClusterSuccess: false, + callScopedClusterSuccess: false, savedObjectsClientSuccess: false, callClusterError: { ...indexedRecord._source.state.callClusterError, statusCode: 403, }, + callScopedClusterError: { + ...indexedRecord._source.state.callScopedClusterError, + statusCode: 403, + }, savedObjectsClientError: { ...indexedRecord._source.state.savedObjectsClientError, output: { @@ -457,6 +462,7 @@ export default function({ getService }: FtrProviderContext) { indexedRecord = searchResult.hits.hits[0]; expect(indexedRecord._source.state).to.eql({ callClusterSuccess: true, + callScopedClusterSuccess: true, savedObjectsClientSuccess: false, savedObjectsClientError: { ...indexedRecord._source.state.savedObjectsClientError, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts index d8e4f808f5cd2..59cf22b52920c 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts @@ -469,11 +469,16 @@ instanceStateValue: true expect(searchResult.hits.total.value).to.eql(1); expect(searchResult.hits.hits[0]._source.state).to.eql({ callClusterSuccess: false, + callScopedClusterSuccess: false, savedObjectsClientSuccess: false, callClusterError: { ...searchResult.hits.hits[0]._source.state.callClusterError, statusCode: 403, }, + callScopedClusterError: { + ...searchResult.hits.hits[0]._source.state.callScopedClusterError, + statusCode: 403, + }, savedObjectsClientError: { ...searchResult.hits.hits[0]._source.state.savedObjectsClientError, output: { @@ -497,6 +502,7 @@ instanceStateValue: true expect(searchResult.hits.total.value).to.eql(1); expect(searchResult.hits.hits[0]._source.state).to.eql({ callClusterSuccess: true, + callScopedClusterSuccess: true, savedObjectsClientSuccess: false, savedObjectsClientError: { ...searchResult.hits.hits[0]._source.state.savedObjectsClientError, @@ -577,11 +583,16 @@ instanceStateValue: true expect(searchResult.hits.total.value).to.eql(1); expect(searchResult.hits.hits[0]._source.state).to.eql({ callClusterSuccess: false, + callScopedClusterSuccess: false, savedObjectsClientSuccess: false, callClusterError: { ...searchResult.hits.hits[0]._source.state.callClusterError, statusCode: 403, }, + callScopedClusterError: { + ...searchResult.hits.hits[0]._source.state.callScopedClusterError, + statusCode: 403, + }, savedObjectsClientError: { ...searchResult.hits.hits[0]._source.state.savedObjectsClientError, output: { @@ -605,6 +616,7 @@ instanceStateValue: true expect(searchResult.hits.total.value).to.eql(1); expect(searchResult.hits.hits[0]._source.state).to.eql({ callClusterSuccess: true, + callScopedClusterSuccess: true, savedObjectsClientSuccess: false, savedObjectsClientError: { ...searchResult.hits.hits[0]._source.state.savedObjectsClientError, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts index 3faa54ee0b219..715573ef1237e 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts @@ -186,6 +186,7 @@ export default function({ getService }: FtrProviderContext) { const indexedRecord = searchResult.hits.hits[0]; expect(indexedRecord._source.state).to.eql({ callClusterSuccess: true, + callScopedClusterSuccess: true, savedObjectsClientSuccess: false, savedObjectsClientError: { ...indexedRecord._source.state.savedObjectsClientError, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_base.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_base.ts index 0d1596a95bfbb..95ccfb897cf54 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_base.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_base.ts @@ -277,6 +277,7 @@ instanceStateValue: true )[0]; expect(alertTestRecord._source.state).to.eql({ callClusterSuccess: true, + callScopedClusterSuccess: true, savedObjectsClientSuccess: false, savedObjectsClientError: { ...alertTestRecord._source.state.savedObjectsClientError, @@ -332,6 +333,7 @@ instanceStateValue: true )[0]; expect(actionTestRecord._source.state).to.eql({ callClusterSuccess: true, + callScopedClusterSuccess: true, savedObjectsClientSuccess: false, savedObjectsClientError: { ...actionTestRecord._source.state.savedObjectsClientError, diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts index 2c29954528bd5..1b75f4a27766a 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts @@ -17,6 +17,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const log = getService('log'); const alerting = getService('alerting'); const retry = getService('retry'); + const find = getService('find'); describe('Alert Details', function() { describe('Header', function() { @@ -148,6 +149,56 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); }); + describe('Edit alert button', function() { + const testRunUuid = uuid.v4(); + + it('should open edit alert flyout', async () => { + await pageObjects.common.navigateToApp('triggersActions'); + const params = { + aggType: 'count', + termSize: 5, + thresholdComparator: '>', + timeWindowSize: 5, + timeWindowUnit: 'm', + groupBy: 'all', + threshold: [1000, 5000], + index: ['.kibana_1'], + timeField: 'alert', + }; + const alert = await alerting.alerts.createAlertWithActions( + testRunUuid, + '.index-threshold', + params + ); + // refresh to see alert + await browser.refresh(); + + await pageObjects.header.waitUntilLoadingHasFinished(); + + // Verify content + await testSubjects.existOrFail('alertsList'); + + // click on first alert + await pageObjects.triggersActionsUI.clickOnAlertInAlertsList(alert.name); + + const editButton = await testSubjects.find('openEditAlertFlyoutButton'); + await editButton.click(); + + const updatedAlertName = `Changed Alert Name ${uuid.v4()}`; + await testSubjects.setValue('alertNameInput', updatedAlertName, { + clearWithKeyboard: true, + }); + + await find.clickByCssSelector('[data-test-subj="saveEditedAlertButton"]:not(disabled)'); + + const toastTitle = await pageObjects.common.closeToast(); + expect(toastTitle).to.eql(`Updated '${updatedAlertName}'`); + + const headingText = await pageObjects.alertDetailsUI.getHeadingText(); + expect(headingText).to.be(updatedAlertName); + }); + }); + describe('View In App', function() { const testRunUuid = uuid.v4(); diff --git a/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts b/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts index 5b506c20e029c..2a0d28f246765 100644 --- a/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts +++ b/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts @@ -22,6 +22,44 @@ export class Alerts { }); } + public async createAlertWithActions( + name: string, + alertTypeId: string, + params?: Record, + actions?: Array<{ + id: string; + group: string; + params: Record; + }>, + tags?: string[], + consumer?: string, + schedule?: Record, + throttle?: string + ) { + this.log.debug(`creating alert ${name}`); + + const { data: alert, status, statusText } = await this.axios.post(`/api/alert`, { + enabled: true, + name, + tags, + alertTypeId, + consumer: consumer ?? 'bar', + schedule: schedule ?? { interval: '1m' }, + throttle: throttle ?? '1m', + actions: actions ?? [], + params: params ?? {}, + }); + if (status !== 200) { + throw new Error( + `Expected status code of 200, received ${status} ${statusText}: ${util.inspect(alert)}` + ); + } + + this.log.debug(`created alert ${alert.id}`); + + return alert; + } + public async createNoOp(name: string) { this.log.debug(`creating alert ${name}`);