diff --git a/docs/settings/apm-settings.asciidoc b/docs/settings/apm-settings.asciidoc index b32c340df4adf..79fa9a642428a 100644 --- a/docs/settings/apm-settings.asciidoc +++ b/docs/settings/apm-settings.asciidoc @@ -43,6 +43,9 @@ Changing these settings may disable features of the APM App. | `xpack.apm.enabled` | Set to `false` to disable the APM app. Defaults to `true`. +| `xpack.apm.maxServiceEnvironments` + | Maximum number of unique service environments recognized by the UI. Defaults to `100`. + | `xpack.apm.serviceMapFingerprintBucketSize` | Maximum number of unique transaction combinations sampled for generating service map focused on a specific service. Defaults to `100`. diff --git a/package.json b/package.json index 821975d11c638..ade567c840da7 100644 --- a/package.json +++ b/package.json @@ -844,8 +844,8 @@ "vinyl-fs": "^3.0.3", "wait-on": "^5.0.1", "watchpack": "^1.6.0", - "webpack-cli": "^3.3.10", - "webpack-dev-server": "^3.8.2", + "webpack-cli": "^3.3.12", + "webpack-dev-server": "^3.11.0", "webpack-merge": "^4.2.2", "write-pkg": "^4.0.0", "xml-crypto": "^2.0.0", diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts index 333f5caf72525..a8c5df8d64630 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts @@ -21,28 +21,64 @@ import { esKuery } from '../../../es_query'; type KueryNode = any; -import { typeRegistryMock } from '../../../saved_objects_type_registry.mock'; +import { SavedObjectTypeRegistry } from '../../../saved_objects_type_registry'; import { ALL_NAMESPACES_STRING } from '../utils'; import { getQueryParams, getClauseForReference } from './query_params'; -const registry = typeRegistryMock.create(); +const registerTypes = (registry: SavedObjectTypeRegistry) => { + registry.registerType({ + name: 'pending', + hidden: false, + namespaceType: 'single', + mappings: { + properties: { title: { type: 'text' } }, + }, + management: { + defaultSearchField: 'title', + }, + }); -const MAPPINGS = { - properties: { - pending: { properties: { title: { type: 'text' } } }, - saved: { + registry.registerType({ + name: 'saved', + hidden: false, + namespaceType: 'single', + mappings: { properties: { title: { type: 'text', fields: { raw: { type: 'keyword' } } }, obj: { properties: { key1: { type: 'text' } } }, }, }, - // mock registry returns isMultiNamespace=true for 'shared' type - shared: { properties: { name: { type: 'keyword' } } }, - // mock registry returns isNamespaceAgnostic=true for 'global' type - global: { properties: { name: { type: 'keyword' } } }, - }, + management: { + defaultSearchField: 'title', + }, + }); + + registry.registerType({ + name: 'shared', + hidden: false, + namespaceType: 'multiple', + mappings: { + properties: { name: { type: 'keyword' } }, + }, + management: { + defaultSearchField: 'name', + }, + }); + + registry.registerType({ + name: 'global', + hidden: false, + namespaceType: 'agnostic', + mappings: { + properties: { name: { type: 'keyword' } }, + }, + management: { + defaultSearchField: 'name', + }, + }); }; -const ALL_TYPES = Object.keys(MAPPINGS.properties); + +const ALL_TYPES = ['pending', 'saved', 'shared', 'global']; // get all possible subsets (combination) of all types const ALL_TYPE_SUBSETS = ALL_TYPES.reduce( (subsets, value) => subsets.concat(subsets.map((set) => [...set, value])), @@ -51,48 +87,53 @@ const ALL_TYPE_SUBSETS = ALL_TYPES.reduce( .filter((x) => x.length) // exclude empty set .map((x) => (x.length === 1 ? x[0] : x)); // if a subset is a single string, destructure it -const createTypeClause = (type: string, namespaces?: string[]) => { - if (registry.isMultiNamespace(type)) { - const array = [...(namespaces ?? ['default']), ALL_NAMESPACES_STRING]; - return { - bool: { - must: expect.arrayContaining([{ terms: { namespaces: array } }]), - must_not: [{ exists: { field: 'namespace' } }], - }, - }; - } else if (registry.isSingleNamespace(type)) { - const nonDefaultNamespaces = namespaces?.filter((n) => n !== 'default') ?? []; - const should: any = []; - if (nonDefaultNamespaces.length > 0) { - should.push({ terms: { namespace: nonDefaultNamespaces } }); - } - if (namespaces?.includes('default')) { - should.push({ bool: { must_not: [{ exists: { field: 'namespace' } }] } }); - } - return { - bool: { - must: [{ term: { type } }], - should: expect.arrayContaining(should), - minimum_should_match: 1, - must_not: [{ exists: { field: 'namespaces' } }], - }, - }; - } - // isNamespaceAgnostic - return { - bool: expect.objectContaining({ - must_not: [{ exists: { field: 'namespace' } }, { exists: { field: 'namespaces' } }], - }), - }; -}; - /** * Note: these tests cases are defined in the order they appear in the source code, for readability's sake */ describe('#getQueryParams', () => { - const mappings = MAPPINGS; + let registry: SavedObjectTypeRegistry; type Result = ReturnType; + beforeEach(() => { + registry = new SavedObjectTypeRegistry(); + registerTypes(registry); + }); + + const createTypeClause = (type: string, namespaces?: string[]) => { + if (registry.isMultiNamespace(type)) { + const array = [...(namespaces ?? ['default']), ALL_NAMESPACES_STRING]; + return { + bool: { + must: expect.arrayContaining([{ terms: { namespaces: array } }]), + must_not: [{ exists: { field: 'namespace' } }], + }, + }; + } else if (registry.isSingleNamespace(type)) { + const nonDefaultNamespaces = namespaces?.filter((n) => n !== 'default') ?? []; + const should: any = []; + if (nonDefaultNamespaces.length > 0) { + should.push({ terms: { namespace: nonDefaultNamespaces } }); + } + if (namespaces?.includes('default')) { + should.push({ bool: { must_not: [{ exists: { field: 'namespace' } }] } }); + } + return { + bool: { + must: [{ term: { type } }], + should: expect.arrayContaining(should), + minimum_should_match: 1, + must_not: [{ exists: { field: 'namespaces' } }], + }, + }; + } + // isNamespaceAgnostic + return { + bool: expect.objectContaining({ + must_not: [{ exists: { field: 'namespace' } }, { exists: { field: 'namespaces' } }], + }), + }; + }; + describe('kueryNode filter clause', () => { const expectResult = (result: Result, expected: any) => { expect(result.query.bool.filter).toEqual(expect.arrayContaining([expected])); @@ -100,13 +141,13 @@ describe('#getQueryParams', () => { describe('`kueryNode` parameter', () => { it('does not include the clause when `kueryNode` is not specified', () => { - const result = getQueryParams({ mappings, registry, kueryNode: undefined }); + const result = getQueryParams({ registry, kueryNode: undefined }); expect(result.query.bool.filter).toHaveLength(1); }); it('includes the specified Kuery clause', () => { const test = (kueryNode: KueryNode) => { - const result = getQueryParams({ mappings, registry, kueryNode }); + const result = getQueryParams({ registry, kueryNode }); const expected = esKuery.toElasticsearchQuery(kueryNode); expect(result.query.bool.filter).toHaveLength(2); expectResult(result, expected); @@ -165,7 +206,6 @@ describe('#getQueryParams', () => { it('does not include the clause when `hasReference` is not specified', () => { const result = getQueryParams({ - mappings, registry, hasReference: undefined, }); @@ -176,7 +216,6 @@ describe('#getQueryParams', () => { it('creates a should clause for specified reference when operator is `OR`', () => { const hasReference = { id: 'foo', type: 'bar' }; const result = getQueryParams({ - mappings, registry, hasReference, hasReferenceOperator: 'OR', @@ -192,7 +231,6 @@ describe('#getQueryParams', () => { it('creates a must clause for specified reference when operator is `AND`', () => { const hasReference = { id: 'foo', type: 'bar' }; const result = getQueryParams({ - mappings, registry, hasReference, hasReferenceOperator: 'AND', @@ -210,7 +248,6 @@ describe('#getQueryParams', () => { { id: 'hello', type: 'dolly' }, ]; const result = getQueryParams({ - mappings, registry, hasReference, hasReferenceOperator: 'OR', @@ -229,7 +266,6 @@ describe('#getQueryParams', () => { { id: 'hello', type: 'dolly' }, ]; const result = getQueryParams({ - mappings, registry, hasReference, hasReferenceOperator: 'AND', @@ -244,7 +280,6 @@ describe('#getQueryParams', () => { it('defaults to `OR` when operator is not specified', () => { const hasReference = { id: 'foo', type: 'bar' }; const result = getQueryParams({ - mappings, registry, hasReference, }); @@ -278,14 +313,13 @@ describe('#getQueryParams', () => { }; it('searches for all known types when `type` is not specified', () => { - const result = getQueryParams({ mappings, registry, type: undefined }); + const result = getQueryParams({ registry, type: undefined }); expectResult(result, ...ALL_TYPES); }); it('searches for specified type/s', () => { const test = (typeOrTypes: string | string[]) => { const result = getQueryParams({ - mappings, registry, type: typeOrTypes, }); @@ -309,18 +343,17 @@ describe('#getQueryParams', () => { const test = (namespaces?: string[]) => { for (const typeOrTypes of ALL_TYPE_SUBSETS) { - const result = getQueryParams({ mappings, registry, type: typeOrTypes, namespaces }); + const result = getQueryParams({ registry, type: typeOrTypes, namespaces }); const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; expectResult(result, ...types.map((x) => createTypeClause(x, namespaces))); } // also test with no specified type/s - const result = getQueryParams({ mappings, registry, type: undefined, namespaces }); + const result = getQueryParams({ registry, type: undefined, namespaces }); expectResult(result, ...ALL_TYPES.map((x) => createTypeClause(x, namespaces))); }; it('normalizes and deduplicates provided namespaces', () => { const result = getQueryParams({ - mappings, registry, search: '*', namespaces: ['foo', '*', 'foo', 'bar', 'default'], @@ -360,7 +393,6 @@ describe('#getQueryParams', () => { it('supersedes `type` and `namespaces` parameters', () => { const result = getQueryParams({ - mappings, registry, type: ['pending', 'saved', 'shared', 'global'], namespaces: ['foo', 'bar', 'default'], @@ -381,148 +413,266 @@ describe('#getQueryParams', () => { }); }); - describe('search clause (query.bool.must.simple_query_string)', () => { - const search = 'foo*'; + describe('search clause (query.bool)', () => { + describe('when using simple search (query.bool.must.simple_query_string)', () => { + const search = 'foo'; - const expectResult = (result: Result, sqsClause: any) => { - expect(result.query.bool.must).toEqual([{ simple_query_string: sqsClause }]); - }; + const expectResult = (result: Result, sqsClause: any) => { + expect(result.query.bool.must).toEqual([{ simple_query_string: sqsClause }]); + }; - describe('`search` parameter', () => { - it('does not include clause when `search` is not specified', () => { - const result = getQueryParams({ - mappings, - registry, - search: undefined, + describe('`search` parameter', () => { + it('does not include clause when `search` is not specified', () => { + const result = getQueryParams({ + registry, + search: undefined, + }); + expect(result.query.bool.must).toBeUndefined(); }); - expect(result.query.bool.must).toBeUndefined(); - }); - it('creates a clause with query for specified search', () => { - const result = getQueryParams({ - mappings, - registry, - search, + it('creates a clause with query for specified search', () => { + const result = getQueryParams({ + registry, + search, + }); + expectResult(result, expect.objectContaining({ query: search })); }); - expectResult(result, expect.objectContaining({ query: search })); }); - }); - describe('`searchFields` and `rootSearchFields` parameters', () => { - const getExpectedFields = (searchFields: string[], typeOrTypes: string | string[]) => { - const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; - return searchFields.map((x) => types.map((y) => `${y}.${x}`)).flat(); - }; + describe('`searchFields` and `rootSearchFields` parameters', () => { + const getExpectedFields = (searchFields: string[], typeOrTypes: string | string[]) => { + const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; + return searchFields.map((x) => types.map((y) => `${y}.${x}`)).flat(); + }; - const test = ({ - searchFields, - rootSearchFields, - }: { - searchFields?: string[]; - rootSearchFields?: string[]; - }) => { - for (const typeOrTypes of ALL_TYPE_SUBSETS) { + const test = ({ + searchFields, + rootSearchFields, + }: { + searchFields?: string[]; + rootSearchFields?: string[]; + }) => { + for (const typeOrTypes of ALL_TYPE_SUBSETS) { + const result = getQueryParams({ + registry, + type: typeOrTypes, + search, + searchFields, + rootSearchFields, + }); + let fields = rootSearchFields || []; + if (searchFields) { + fields = fields.concat(getExpectedFields(searchFields, typeOrTypes)); + } + expectResult(result, expect.objectContaining({ fields })); + } + // also test with no specified type/s const result = getQueryParams({ - mappings, registry, - type: typeOrTypes, + type: undefined, search, searchFields, rootSearchFields, }); let fields = rootSearchFields || []; if (searchFields) { - fields = fields.concat(getExpectedFields(searchFields, typeOrTypes)); + fields = fields.concat(getExpectedFields(searchFields, ALL_TYPES)); } expectResult(result, expect.objectContaining({ fields })); - } - // also test with no specified type/s - const result = getQueryParams({ - mappings, - registry, - type: undefined, - search, - searchFields, - rootSearchFields, + }; + + it('throws an error if a raw search field contains a "." character', () => { + expect(() => + getQueryParams({ + registry, + type: undefined, + search, + searchFields: undefined, + rootSearchFields: ['foo', 'bar.baz'], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"rootSearchFields entry \\"bar.baz\\" is invalid: cannot contain \\".\\" character"` + ); }); - let fields = rootSearchFields || []; - if (searchFields) { - fields = fields.concat(getExpectedFields(searchFields, ALL_TYPES)); - } - expectResult(result, expect.objectContaining({ fields })); - }; - it('throws an error if a raw search field contains a "." character', () => { - expect(() => - getQueryParams({ - mappings, + it('includes lenient flag and all fields when `searchFields` and `rootSearchFields` are not specified', () => { + const result = getQueryParams({ registry, - type: undefined, search, searchFields: undefined, - rootSearchFields: ['foo', 'bar.baz'], - }) - ).toThrowErrorMatchingInlineSnapshot( - `"rootSearchFields entry \\"bar.baz\\" is invalid: cannot contain \\".\\" character"` - ); + rootSearchFields: undefined, + }); + expectResult(result, expect.objectContaining({ lenient: true, fields: ['*'] })); + }); + + it('includes specified search fields for appropriate type/s', () => { + test({ searchFields: ['title'] }); + }); + + it('supports boosting', () => { + test({ searchFields: ['title^3'] }); + }); + + it('supports multiple search fields', () => { + test({ searchFields: ['title, title.raw'] }); + }); + + it('includes specified raw search fields', () => { + test({ rootSearchFields: ['_id'] }); + }); + + it('supports multiple raw search fields', () => { + test({ rootSearchFields: ['_id', 'originId'] }); + }); + + it('supports search fields and raw search fields', () => { + test({ searchFields: ['title'], rootSearchFields: ['_id'] }); + }); }); - it('includes lenient flag and all fields when `searchFields` and `rootSearchFields` are not specified', () => { - const result = getQueryParams({ - mappings, + describe('`defaultSearchOperator` parameter', () => { + it('does not include default_operator when `defaultSearchOperator` is not specified', () => { + const result = getQueryParams({ + registry, + search, + defaultSearchOperator: undefined, + }); + expectResult( + result, + expect.not.objectContaining({ default_operator: expect.anything() }) + ); + }); + + it('includes specified default operator', () => { + const defaultSearchOperator = 'AND'; + const result = getQueryParams({ + registry, + search, + defaultSearchOperator, + }); + expectResult( + result, + expect.objectContaining({ default_operator: defaultSearchOperator }) + ); + }); + }); + }); + + describe('when using prefix search (query.bool.should)', () => { + const searchQuery = 'foo*'; + + const getQueryParamForSearch = ({ + search, + searchFields, + type, + }: { + search?: string; + searchFields?: string[]; + type?: string[]; + }) => + getQueryParams({ registry, search, - searchFields: undefined, - rootSearchFields: undefined, + searchFields, + type, }); - expectResult(result, expect.objectContaining({ lenient: true, fields: ['*'] })); - }); - it('includes specified search fields for appropriate type/s', () => { - test({ searchFields: ['title'] }); - }); + it('uses a `should` clause instead of `must`', () => { + const result = getQueryParamForSearch({ search: searchQuery, searchFields: ['title'] }); - it('supports boosting', () => { - test({ searchFields: ['title^3'] }); + expect(result.query.bool.must).toBeUndefined(); + expect(result.query.bool.should).toEqual(expect.any(Array)); + expect(result.query.bool.should.length).toBeGreaterThanOrEqual(1); + expect(result.query.bool.minimum_should_match).toBe(1); }); - - it('supports multiple search fields', () => { - test({ searchFields: ['title, title.raw'] }); + it('includes the `simple_query_string` in the `should` clauses', () => { + const result = getQueryParamForSearch({ search: searchQuery, searchFields: ['title'] }); + expect(result.query.bool.should[0]).toEqual({ + simple_query_string: expect.objectContaining({ + query: searchQuery, + }), + }); }); - it('includes specified raw search fields', () => { - test({ rootSearchFields: ['_id'] }); + it('adds a should clause for each `searchFields` / `type` tuple', () => { + const result = getQueryParamForSearch({ + search: searchQuery, + searchFields: ['title', 'desc'], + type: ['saved', 'pending'], + }); + const shouldClauses = result.query.bool.should; + + expect(shouldClauses.length).toBe(5); + + const mppClauses = shouldClauses.slice(1); + + expect( + mppClauses.map((clause: any) => Object.keys(clause.match_phrase_prefix)[0]) + ).toEqual(['saved.title', 'pending.title', 'saved.desc', 'pending.desc']); }); - it('supports multiple raw search fields', () => { - test({ rootSearchFields: ['_id', 'originId'] }); + it('uses all registered types when `type` is not provided', () => { + const result = getQueryParamForSearch({ + search: searchQuery, + searchFields: ['title'], + type: undefined, + }); + const shouldClauses = result.query.bool.should; + + expect(shouldClauses.length).toBe(5); + + const mppClauses = shouldClauses.slice(1); + + expect( + mppClauses.map((clause: any) => Object.keys(clause.match_phrase_prefix)[0]) + ).toEqual(['pending.title', 'saved.title', 'shared.title', 'global.title']); }); - it('supports search fields and raw search fields', () => { - test({ searchFields: ['title'], rootSearchFields: ['_id'] }); + it('removes the prefix search wildcard from the query', () => { + const result = getQueryParamForSearch({ + search: searchQuery, + searchFields: ['title'], + type: ['saved'], + }); + const shouldClauses = result.query.bool.should; + const mppClauses = shouldClauses.slice(1); + + expect(mppClauses[0].match_phrase_prefix['saved.title'].query).toEqual('foo'); }); - }); - describe('`defaultSearchOperator` parameter', () => { - it('does not include default_operator when `defaultSearchOperator` is not specified', () => { - const result = getQueryParams({ - mappings, - registry, - search, - defaultSearchOperator: undefined, + it("defaults to the type's default search field when `searchFields` is not specified", () => { + const result = getQueryParamForSearch({ + search: searchQuery, + searchFields: undefined, + type: ['saved', 'global'], }); - expectResult(result, expect.not.objectContaining({ default_operator: expect.anything() })); + const shouldClauses = result.query.bool.should; + + expect(shouldClauses.length).toBe(3); + + const mppClauses = shouldClauses.slice(1); + + expect( + mppClauses.map((clause: any) => Object.keys(clause.match_phrase_prefix)[0]) + ).toEqual(['saved.title', 'global.name']); }); - it('includes specified default operator', () => { - const defaultSearchOperator = 'AND'; - const result = getQueryParams({ - mappings, - registry, - search, - defaultSearchOperator, + it('supports boosting', () => { + const result = getQueryParamForSearch({ + search: searchQuery, + searchFields: ['title^3', 'description'], + type: ['saved'], }); - expectResult(result, expect.objectContaining({ default_operator: defaultSearchOperator })); + const shouldClauses = result.query.bool.should; + + expect(shouldClauses.length).toBe(3); + + const mppClauses = shouldClauses.slice(1); + + expect(mppClauses.map((clause: any) => clause.match_phrase_prefix)).toEqual([ + { 'saved.title': { query: 'foo', boost: 3 } }, + { 'saved.description': { query: 'foo', boost: 1 } }, + ]); }); }); }); @@ -532,7 +682,6 @@ describe('#getQueryParams', () => { it(`throws for ${type} when namespaces is an empty array`, () => { expect(() => getQueryParams({ - mappings, registry, namespaces: [], }) diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts index 8d4fe13b9bede..f73777c4f454f 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts @@ -20,7 +20,6 @@ import { esKuery } from '../../../es_query'; type KueryNode = any; -import { getRootPropertiesObjects, IndexMapping } from '../../../mappings'; import { ISavedObjectTypeRegistry } from '../../../saved_objects_type_registry'; import { ALL_NAMESPACES_STRING, DEFAULT_NAMESPACE_STRING } from '../utils'; @@ -28,22 +27,17 @@ import { ALL_NAMESPACES_STRING, DEFAULT_NAMESPACE_STRING } from '../utils'; * Gets the types based on the type. Uses mappings to support * null type (all types), a single type string or an array */ -function getTypes(mappings: IndexMapping, type?: string | string[]) { +function getTypes(registry: ISavedObjectTypeRegistry, type?: string | string[]) { if (!type) { - return Object.keys(getRootPropertiesObjects(mappings)); + return registry.getAllTypes().map((registeredType) => registeredType.name); } - - if (Array.isArray(type)) { - return type; - } - - return [type]; + return Array.isArray(type) ? type : [type]; } /** * Get the field params based on the types, searchFields, and rootSearchFields */ -function getFieldsForTypes( +function getSimpleQueryStringTypeFields( types: string[], searchFields: string[] = [], rootSearchFields: string[] = [] @@ -130,7 +124,6 @@ export interface HasReferenceQueryParams { export type SearchOperator = 'AND' | 'OR'; interface QueryParams { - mappings: IndexMapping; registry: ISavedObjectTypeRegistry; namespaces?: string[]; type?: string | string[]; @@ -188,11 +181,26 @@ export function getClauseForReference(reference: HasReferenceQueryParams) { }; } +// A de-duplicated set of namespaces makes for a more efficient query. +// +// Additionally, we treat the `*` namespace as the `default` namespace. +// In the Default Distribution, the `*` is automatically expanded to include all available namespaces. +// However, the OSS distribution (and certain configurations of the Default Distribution) can allow the `*` +// to pass through to the SO Repository, and eventually to this module. When this happens, we translate to `default`, +// since that is consistent with how a single-namespace search behaves in the OSS distribution. Leaving the wildcard in place +// would result in no results being returned, as the wildcard is treated as a literal, and not _actually_ as a wildcard. +// We had a good discussion around the tradeoffs here: https://github.com/elastic/kibana/pull/67644#discussion_r441055716 +const normalizeNamespaces = (namespacesToNormalize?: string[]) => + namespacesToNormalize + ? Array.from( + new Set(namespacesToNormalize.map((x) => (x === '*' ? DEFAULT_NAMESPACE_STRING : x))) + ) + : undefined; + /** * Get the "query" related keys for the search body */ export function getQueryParams({ - mappings, registry, namespaces, type, @@ -206,7 +214,7 @@ export function getQueryParams({ kueryNode, }: QueryParams) { const types = getTypes( - mappings, + registry, typeToNamespacesMap ? Array.from(typeToNamespacesMap.keys()) : type ); @@ -214,28 +222,10 @@ export function getQueryParams({ hasReference = [hasReference]; } - // A de-duplicated set of namespaces makes for a more effecient query. - // - // Additonally, we treat the `*` namespace as the `default` namespace. - // In the Default Distribution, the `*` is automatically expanded to include all available namespaces. - // However, the OSS distribution (and certain configurations of the Default Distribution) can allow the `*` - // to pass through to the SO Repository, and eventually to this module. When this happens, we translate to `default`, - // since that is consistent with how a single-namespace search behaves in the OSS distribution. Leaving the wildcard in place - // would result in no results being returned, as the wildcard is treated as a literal, and not _actually_ as a wildcard. - // We had a good discussion around the tradeoffs here: https://github.com/elastic/kibana/pull/67644#discussion_r441055716 - const normalizeNamespaces = (namespacesToNormalize?: string[]) => - namespacesToNormalize - ? Array.from( - new Set(namespacesToNormalize.map((x) => (x === '*' ? DEFAULT_NAMESPACE_STRING : x))) - ) - : undefined; - const bool: any = { filter: [ ...(kueryNode != null ? [esKuery.toElasticsearchQuery(kueryNode)] : []), - ...(hasReference && hasReference.length - ? [getReferencesFilter(hasReference, hasReferenceOperator)] - : []), + ...(hasReference?.length ? [getReferencesFilter(hasReference, hasReferenceOperator)] : []), { bool: { should: types.map((shouldType) => { @@ -251,16 +241,133 @@ export function getQueryParams({ }; if (search) { - bool.must = [ - { - simple_query_string: { - query: search, - ...getFieldsForTypes(types, searchFields, rootSearchFields), - ...(defaultSearchOperator ? { default_operator: defaultSearchOperator } : {}), - }, - }, - ]; + const useMatchPhrasePrefix = shouldUseMatchPhrasePrefix(search); + const simpleQueryStringClause = getSimpleQueryStringClause({ + search, + types, + searchFields, + rootSearchFields, + defaultSearchOperator, + }); + + if (useMatchPhrasePrefix) { + bool.should = [ + simpleQueryStringClause, + ...getMatchPhrasePrefixClauses({ search, searchFields, types, registry }), + ]; + bool.minimum_should_match = 1; + } else { + bool.must = [simpleQueryStringClause]; + } } return { query: { bool } }; } + +// we only want to add match_phrase_prefix clauses +// if the search is a prefix search +const shouldUseMatchPhrasePrefix = (search: string): boolean => { + return search.trim().endsWith('*'); +}; + +const getMatchPhrasePrefixClauses = ({ + search, + searchFields, + registry, + types, +}: { + search: string; + searchFields?: string[]; + types: string[]; + registry: ISavedObjectTypeRegistry; +}) => { + // need to remove the prefix search operator + const query = search.replace(/[*]$/, ''); + const mppFields = getMatchPhrasePrefixFields({ searchFields, types, registry }); + return mppFields.map(({ field, boost }) => { + return { + match_phrase_prefix: { + [field]: { + query, + boost, + }, + }, + }; + }); +}; + +interface FieldWithBoost { + field: string; + boost?: number; +} + +const getMatchPhrasePrefixFields = ({ + searchFields = [], + types, + registry, +}: { + searchFields?: string[]; + types: string[]; + registry: ISavedObjectTypeRegistry; +}): FieldWithBoost[] => { + const output: FieldWithBoost[] = []; + + searchFields = searchFields.filter((field) => field !== '*'); + let fields: string[]; + if (searchFields.length === 0) { + fields = types.reduce((typeFields, type) => { + const defaultSearchField = registry.getType(type)?.management?.defaultSearchField; + if (defaultSearchField) { + return [...typeFields, `${type}.${defaultSearchField}`]; + } + return typeFields; + }, [] as string[]); + } else { + fields = []; + for (const field of searchFields) { + fields = fields.concat(types.map((type) => `${type}.${field}`)); + } + } + + fields.forEach((rawField) => { + const [field, rawBoost] = rawField.split('^'); + let boost: number = 1; + if (rawBoost) { + try { + boost = parseInt(rawBoost, 10); + } catch (e) { + boost = 1; + } + } + if (isNaN(boost)) { + boost = 1; + } + output.push({ + field, + boost, + }); + }); + return output; +}; + +const getSimpleQueryStringClause = ({ + search, + types, + searchFields, + rootSearchFields, + defaultSearchOperator, +}: { + search: string; + types: string[]; + searchFields?: string[]; + rootSearchFields?: string[]; + defaultSearchOperator?: SearchOperator; +}) => { + return { + simple_query_string: { + query: search, + ...getSimpleQueryStringTypeFields(types, searchFields, rootSearchFields), + ...(defaultSearchOperator ? { default_operator: defaultSearchOperator } : {}), + }, + }; +}; diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts index a9f26f71a3f2b..3522ab9ef1736 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts @@ -76,7 +76,6 @@ describe('getSearchDsl', () => { getSearchDsl(mappings, registry, opts); expect(getQueryParams).toHaveBeenCalledTimes(1); expect(getQueryParams).toHaveBeenCalledWith({ - mappings, registry, namespaces: opts.namespaces, type: opts.type, diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts index d5da82e5617be..bddecc4d7f649 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts @@ -71,7 +71,6 @@ export function getSearchDsl( return { ...getQueryParams({ - mappings, registry, namespaces, type, diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts index 9085ae07bbe3e..145901509d1c5 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts @@ -196,6 +196,24 @@ describe('IndexPattern', () => { }); }); + describe('getFormatterForField', () => { + test('should return the default one for empty objects', () => { + indexPattern.setFieldFormat('scriptedFieldWithEmptyFormatter', {}); + expect( + indexPattern.getFormatterForField({ + name: 'scriptedFieldWithEmptyFormatter', + type: 'number', + esTypes: ['long'], + }) + ).toEqual( + expect.objectContaining({ + convert: expect.any(Function), + getConverterFor: expect.any(Function), + }) + ); + }); + }); + describe('toSpec', () => { test('should match snapshot', () => { const formatter = { diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts index a0f27078543a9..4508d7b1d9082 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts @@ -291,15 +291,15 @@ export class IndexPattern implements IIndexPattern { getFormatterForField( field: IndexPatternField | IndexPatternField['spec'] | IFieldType ): FieldFormat { - const formatSpec = this.fieldFormatMap[field.name]; - if (formatSpec) { - return this.fieldFormats.getInstance(formatSpec.id, formatSpec.params); - } else { - return this.fieldFormats.getDefaultInstance( - field.type as KBN_FIELD_TYPES, - field.esTypes as ES_FIELD_TYPES[] - ); + const fieldFormat = this.getFormatterForFieldNoDefault(field.name); + if (fieldFormat) { + return fieldFormat; } + + return this.fieldFormats.getDefaultInstance( + field.type as KBN_FIELD_TYPES, + field.esTypes as ES_FIELD_TYPES[] + ); } /** @@ -308,7 +308,7 @@ export class IndexPattern implements IIndexPattern { */ getFormatterForFieldNoDefault(fieldname: string) { const formatSpec = this.fieldFormatMap[fieldname]; - if (formatSpec) { + if (formatSpec?.id) { return this.fieldFormats.getInstance(formatSpec.id, formatSpec.params); } } diff --git a/src/plugins/vis_type_timeseries/common/types.ts b/src/plugins/vis_type_timeseries/common/types.ts index 4520069244527..8973060848b41 100644 --- a/src/plugins/vis_type_timeseries/common/types.ts +++ b/src/plugins/vis_type_timeseries/common/types.ts @@ -18,8 +18,9 @@ */ import { TypeOf } from '@kbn/config-schema'; -import { metricsItems, panel, seriesItems } from './vis_schema'; +import { metricsItems, panel, seriesItems, visPayloadSchema } from './vis_schema'; export type SeriesItemsSchema = TypeOf; export type MetricsItemsSchema = TypeOf; export type PanelSchema = TypeOf; +export type VisPayload = TypeOf; diff --git a/src/plugins/vis_type_timeseries/common/vis_schema.ts b/src/plugins/vis_type_timeseries/common/vis_schema.ts index 40f776050617e..27f09fb574b0f 100644 --- a/src/plugins/vis_type_timeseries/common/vis_schema.ts +++ b/src/plugins/vis_type_timeseries/common/vis_schema.ts @@ -273,4 +273,5 @@ export const visPayloadSchema = schema.object({ min: stringRequired, max: stringRequired, }), + sessionId: schema.maybe(schema.string()), }); diff --git a/src/plugins/vis_type_timeseries/public/request_handler.js b/src/plugins/vis_type_timeseries/public/request_handler.js index e33d0e254f609..12b7f3d417ef6 100644 --- a/src/plugins/vis_type_timeseries/public/request_handler.js +++ b/src/plugins/vis_type_timeseries/public/request_handler.js @@ -32,7 +32,8 @@ export const metricsRequestHandler = async ({ const config = getUISettings(); const timezone = getTimezone(config); const uiStateObj = uiState.get(visParams.type, {}); - const parsedTimeRange = getDataStart().query.timefilter.timefilter.calculateBounds(timeRange); + const dataSearch = getDataStart(); + const parsedTimeRange = dataSearch.query.timefilter.timefilter.calculateBounds(timeRange); const scaledDataFormat = config.get('dateFormat:scaled'); const dateFormat = config.get('dateFormat'); @@ -53,6 +54,7 @@ export const metricsRequestHandler = async ({ panels: [visParams], state: uiStateObj, savedObjectId: savedObjectId || 'unsaved', + sessionId: dataSearch.search.session.getSessionId(), }), }); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js index 9710f7daf69b6..2c38e883cd69f 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js @@ -28,6 +28,7 @@ describe('AbstractSearchStrategy', () => { beforeEach(() => { mockedFields = {}; req = { + payload: {}, pre: { indexPatternsService: { getFieldsForWildcard: jest.fn().mockReturnValue(mockedFields), @@ -60,6 +61,9 @@ describe('AbstractSearchStrategy', () => { const responses = await abstractSearchStrategy.search( { + payload: { + sessionId: 1, + }, requestContext: { search: { search: searchFn }, }, @@ -76,7 +80,9 @@ describe('AbstractSearchStrategy', () => { }, indexType: undefined, }, - {} + { + sessionId: 1, + } ); }); }); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts index eb22fcb1dd689..b1e21edf8b588 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts @@ -23,8 +23,10 @@ import { IUiSettingsClient, SavedObjectsClientContract, } from 'kibana/server'; + import { Framework } from '../../../plugin'; import { IndexPatternsFetcher } from '../../../../../data/server'; +import { VisPayload } from '../../../../common/types'; /** * ReqFacade is a regular KibanaRequest object extended with additional service @@ -32,17 +34,17 @@ import { IndexPatternsFetcher } from '../../../../../data/server'; * * This will be replaced by standard KibanaRequest and RequestContext objects in a later version. */ -export type ReqFacade = FakeRequest & { +export interface ReqFacade extends FakeRequest { requestContext: RequestHandlerContext; framework: Framework; - payload: unknown; + payload: T; pre: { indexPatternsService?: IndexPatternsFetcher; }; getUiSettingsService: () => IUiSettingsClient; getSavedObjectsClient: () => SavedObjectsClientContract; getEsShardTimeout: () => Promise; -}; +} export class AbstractSearchStrategy { public indexType?: string; @@ -53,8 +55,10 @@ export class AbstractSearchStrategy { this.additionalParams = additionalParams; } - async search(req: ReqFacade, bodies: any[], options = {}) { + async search(req: ReqFacade, bodies: any[], options = {}) { const requests: any[] = []; + const { sessionId } = req.payload; + bodies.forEach((body) => { requests.push( req.requestContext @@ -67,6 +71,7 @@ export class AbstractSearchStrategy { indexType: this.indexType, }, { + sessionId, ...options, } ) diff --git a/test/api_integration/apis/saved_objects/find.js b/test/api_integration/apis/saved_objects/find.js index c2e36b4a669ff..e5da46644672b 100644 --- a/test/api_integration/apis/saved_objects/find.js +++ b/test/api_integration/apis/saved_objects/find.js @@ -334,6 +334,70 @@ export default function ({ getService }) { }); }); + describe('searching for special characters', () => { + before(() => esArchiver.load('saved_objects/find_edgecases')); + after(() => esArchiver.unload('saved_objects/find_edgecases')); + + it('can search for objects with dashes', async () => + await supertest + .get('/api/saved_objects/_find') + .query({ + type: 'visualization', + search_fields: 'title', + search: 'my-vis*', + }) + .expect(200) + .then((resp) => { + const savedObjects = resp.body.saved_objects; + expect(savedObjects.map((so) => so.attributes.title)).to.eql(['my-visualization']); + })); + + it('can search with the prefix search character just after a special one', async () => + await supertest + .get('/api/saved_objects/_find') + .query({ + type: 'visualization', + search_fields: 'title', + search: 'my-*', + }) + .expect(200) + .then((resp) => { + const savedObjects = resp.body.saved_objects; + expect(savedObjects.map((so) => so.attributes.title)).to.eql(['my-visualization']); + })); + + it('can search for objects with asterisk', async () => + await supertest + .get('/api/saved_objects/_find') + .query({ + type: 'visualization', + search_fields: 'title', + search: 'some*vi*', + }) + .expect(200) + .then((resp) => { + const savedObjects = resp.body.saved_objects; + expect(savedObjects.map((so) => so.attributes.title)).to.eql(['some*visualization']); + })); + + it('can still search tokens by prefix', async () => + await supertest + .get('/api/saved_objects/_find') + .query({ + type: 'visualization', + search_fields: 'title', + search: 'visuali*', + }) + .expect(200) + .then((resp) => { + const savedObjects = resp.body.saved_objects; + expect(savedObjects.map((so) => so.attributes.title)).to.eql([ + 'my-visualization', + 'some*visualization', + ]); + })); + }); + describe('without kibana index', () => { before( async () => diff --git a/test/api_integration/fixtures/es_archiver/saved_objects/find_edgecases/data.json b/test/api_integration/fixtures/es_archiver/saved_objects/find_edgecases/data.json new file mode 100644 index 0000000000000..0c8b35fd3f499 --- /dev/null +++ b/test/api_integration/fixtures/es_archiver/saved_objects/find_edgecases/data.json @@ -0,0 +1,93 @@ +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "visualization:title-with-dash", + "source": { + "type": "visualization", + "updated_at": "2017-09-21T18:51:23.794Z", + "visualization": { + "title": "my-visualization", + "visState": "{}", + "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + }, + "references": [] + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "visualization:title-with-asterisk", + "source": { + "type": "visualization", + "updated_at": "2017-09-21T18:51:23.794Z", + "visualization": { + "title": "some*visualization", + "visState": "{}", + "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + }, + "references": [] + } + } +} + + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "visualization:noise-1", + "source": { + "type": "visualization", + "updated_at": "2017-09-21T18:51:23.794Z", + "visualization": { + "title": "Just some noise in the dataset", + "visState": "{}", + "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + }, + "references": [] + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "visualization:noise-2", + "source": { + "type": "visualization", + "updated_at": "2017-09-21T18:51:23.794Z", + "visualization": { + "title": "Just some noise in the dataset", + "visState": "{}", + "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + }, + "references": [] + } + } +} + diff --git a/test/api_integration/fixtures/es_archiver/saved_objects/find_edgecases/mappings.json b/test/api_integration/fixtures/es_archiver/saved_objects/find_edgecases/mappings.json new file mode 100644 index 0000000000000..e601c43431437 --- /dev/null +++ b/test/api_integration/fixtures/es_archiver/saved_objects/find_edgecases/mappings.json @@ -0,0 +1,267 @@ +{ + "type": "index", + "value": { + "index": ".kibana", + "settings": { + "index": { + "number_of_shards": "1", + "number_of_replicas": "1" + } + }, + "mappings": { + "dynamic": "strict", + "properties": { + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + }, + "defaultIndex": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + } + } + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "namespace": { + "type": "keyword" + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + } + } +} diff --git a/test/functional/apps/discover/_discover.js b/test/functional/apps/discover/_discover.js index e597cc14654bc..3c9996ca44ff8 100644 --- a/test/functional/apps/discover/_discover.js +++ b/test/functional/apps/discover/_discover.js @@ -32,7 +32,8 @@ export default function ({ getService, getPageObjects }) { defaultIndex: 'logstash-*', }; - describe('discover test', function describeIndexTests() { + // Failing: See https://github.com/elastic/kibana/issues/82915 + describe.skip('discover test', function describeIndexTests() { before(async function () { log.debug('load kibana index with default index pattern'); await esArchiver.load('discover'); diff --git a/x-pack/examples/alerting_example/public/alert_types/astros.tsx b/x-pack/examples/alerting_example/public/alert_types/astros.tsx index 73c7dfea1263b..54f989b93e22f 100644 --- a/x-pack/examples/alerting_example/public/alert_types/astros.tsx +++ b/x-pack/examples/alerting_example/public/alert_types/astros.tsx @@ -127,9 +127,9 @@ export const PeopleinSpaceExpression: React.FunctionComponent - errs.map((e) => ( -

+ Object.entries(errors).map(([field, errs]: [string, string[]], fieldIndex) => + errs.map((e, index) => ( +

{field}:`: ${errs}`

)) diff --git a/x-pack/examples/alerting_example/server/alert_types/always_firing.ts b/x-pack/examples/alerting_example/server/alert_types/always_firing.ts index bb1cb0d97689b..d02406a23045e 100644 --- a/x-pack/examples/alerting_example/server/alert_types/always_firing.ts +++ b/x-pack/examples/alerting_example/server/alert_types/always_firing.ts @@ -5,25 +5,31 @@ */ import uuid from 'uuid'; -import { range } from 'lodash'; +import { range, random } from 'lodash'; import { AlertType } from '../../../../plugins/alerts/server'; import { DEFAULT_INSTANCES_TO_GENERATE, ALERTING_EXAMPLE_APP_ID } from '../../common/constants'; +const ACTION_GROUPS = [ + { id: 'small', name: 'small' }, + { id: 'medium', name: 'medium' }, + { id: 'large', name: 'large' }, +]; + export const alertType: AlertType = { id: 'example.always-firing', name: 'Always firing', - actionGroups: [{ id: 'default', name: 'default' }], - defaultActionGroupId: 'default', + actionGroups: ACTION_GROUPS, + defaultActionGroupId: 'small', async executor({ services, params: { instances = DEFAULT_INSTANCES_TO_GENERATE }, state }) { const count = (state.count ?? 0) + 1; range(instances) - .map(() => ({ id: uuid.v4() })) - .forEach((instance: { id: string }) => { + .map(() => ({ id: uuid.v4(), tshirtSize: ACTION_GROUPS[random(0, 2)].id! })) + .forEach((instance: { id: string; tshirtSize: string }) => { services .alertInstanceFactory(instance.id) .replaceState({ triggerdOnCycle: count }) - .scheduleActions('default'); + .scheduleActions(instance.tshirtSize); }); return { diff --git a/x-pack/plugins/alerts/common/alert.ts b/x-pack/plugins/alerts/common/alert.ts index 79e6bb8f2cbba..97a9a58400e38 100644 --- a/x-pack/plugins/alerts/common/alert.ts +++ b/x-pack/plugins/alerts/common/alert.ts @@ -20,13 +20,12 @@ export interface IntervalSchedule extends SavedObjectAttributes { export const AlertExecutionStatusValues = ['ok', 'active', 'error', 'pending', 'unknown'] as const; export type AlertExecutionStatuses = typeof AlertExecutionStatusValues[number]; -export const AlertExecutionStatusErrorReasonValues = [ - 'read', - 'decrypt', - 'execute', - 'unknown', -] as const; -export type AlertExecutionStatusErrorReasons = typeof AlertExecutionStatusErrorReasonValues[number]; +export enum AlertExecutionStatusErrorReasons { + Read = 'read', + Decrypt = 'decrypt', + Execute = 'execute', + Unknown = 'unknown', +} export interface AlertExecutionStatus { status: AlertExecutionStatuses; @@ -74,3 +73,24 @@ export interface Alert { } export type SanitizedAlert = Omit; + +export enum HealthStatus { + OK = 'ok', + Warning = 'warn', + Error = 'error', +} + +export interface AlertsHealth { + decryptionHealth: { + status: HealthStatus; + timestamp: string; + }; + executionHealth: { + status: HealthStatus; + timestamp: string; + }; + readHealth: { + status: HealthStatus; + timestamp: string; + }; +} diff --git a/x-pack/plugins/alerts/common/index.ts b/x-pack/plugins/alerts/common/index.ts index ab71f77a049f6..65aeec840da7e 100644 --- a/x-pack/plugins/alerts/common/index.ts +++ b/x-pack/plugins/alerts/common/index.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { AlertsHealth } from './alert'; + export * from './alert'; export * from './alert_type'; export * from './alert_instance'; @@ -19,6 +21,7 @@ export interface ActionGroup { export interface AlertingFrameworkHealth { isSufficientlySecure: boolean; hasPermanentEncryptionKey: boolean; + alertingFrameworkHeath: AlertsHealth; } export const BASE_ALERT_API_PATH = '/api/alerts'; diff --git a/x-pack/plugins/alerts/server/config.test.ts b/x-pack/plugins/alerts/server/config.test.ts new file mode 100644 index 0000000000000..93aa3c38a0460 --- /dev/null +++ b/x-pack/plugins/alerts/server/config.test.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { configSchema } from './config'; + +describe('config validation', () => { + test('alerts defaults', () => { + const config: Record = {}; + expect(configSchema.validate(config)).toMatchInlineSnapshot(` + Object { + "healthCheck": Object { + "interval": "60m", + }, + } + `); + }); +}); diff --git a/x-pack/plugins/alerts/server/config.ts b/x-pack/plugins/alerts/server/config.ts new file mode 100644 index 0000000000000..a6d2196a407b5 --- /dev/null +++ b/x-pack/plugins/alerts/server/config.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import { validateDurationSchema } from './lib'; + +export const configSchema = schema.object({ + healthCheck: schema.object({ + interval: schema.string({ validate: validateDurationSchema, defaultValue: '60m' }), + }), +}); + +export type AlertsConfig = TypeOf; diff --git a/x-pack/plugins/alerts/server/health/get_health.test.ts b/x-pack/plugins/alerts/server/health/get_health.test.ts new file mode 100644 index 0000000000000..34517a89f04d9 --- /dev/null +++ b/x-pack/plugins/alerts/server/health/get_health.test.ts @@ -0,0 +1,221 @@ +/* + * 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 { savedObjectsRepositoryMock } from '../../../../../src/core/server/mocks'; +import { AlertExecutionStatusErrorReasons, HealthStatus } from '../types'; +import { getHealth } from './get_health'; + +const savedObjectsRepository = savedObjectsRepositoryMock.create(); + +describe('getHealth()', () => { + test('return true if some of alerts has a decryption error', async () => { + const lastExecutionDateError = new Date().toISOString(); + const lastExecutionDate = new Date().toISOString(); + savedObjectsRepository.find.mockResolvedValueOnce({ + total: 1, + per_page: 1, + page: 1, + saved_objects: [ + { + id: '1', + type: 'alert', + attributes: { + alertTypeId: 'myType', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: new Date().toISOString(), + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + executionStatus: { + status: 'error', + lastExecutionDate: lastExecutionDateError, + error: { + reason: AlertExecutionStatusErrorReasons.Decrypt, + message: 'Failed decrypt', + }, + }, + }, + score: 1, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }, + ], + }); + savedObjectsRepository.find.mockResolvedValueOnce({ + total: 0, + per_page: 10, + page: 1, + saved_objects: [], + }); + + savedObjectsRepository.find.mockResolvedValueOnce({ + total: 0, + per_page: 10, + page: 1, + saved_objects: [], + }); + + savedObjectsRepository.find.mockResolvedValueOnce({ + total: 1, + per_page: 1, + page: 1, + saved_objects: [ + { + id: '2', + type: 'alert', + attributes: { + alertTypeId: 'myType', + schedule: { interval: '1s' }, + params: { + bar: true, + }, + createdAt: new Date().toISOString(), + actions: [], + executionStatus: { + status: 'ok', + lastExecutionDate, + }, + }, + score: 1, + references: [], + }, + ], + }); + const result = await getHealth(savedObjectsRepository); + expect(result).toStrictEqual({ + executionHealth: { + status: HealthStatus.OK, + timestamp: lastExecutionDate, + }, + readHealth: { + status: HealthStatus.OK, + timestamp: lastExecutionDate, + }, + decryptionHealth: { + status: HealthStatus.Warning, + timestamp: lastExecutionDateError, + }, + }); + expect(savedObjectsRepository.find).toHaveBeenCalledTimes(4); + }); + + test('return false if no alerts with a decryption error', async () => { + const lastExecutionDateError = new Date().toISOString(); + const lastExecutionDate = new Date().toISOString(); + savedObjectsRepository.find.mockResolvedValueOnce({ + total: 0, + per_page: 10, + page: 1, + saved_objects: [], + }); + + savedObjectsRepository.find.mockResolvedValueOnce({ + total: 1, + per_page: 1, + page: 1, + saved_objects: [ + { + id: '1', + type: 'alert', + attributes: { + alertTypeId: 'myType', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: new Date().toISOString(), + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + executionStatus: { + status: 'error', + lastExecutionDate: lastExecutionDateError, + error: { + reason: AlertExecutionStatusErrorReasons.Execute, + message: 'Failed', + }, + }, + }, + score: 1, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }, + ], + }); + savedObjectsRepository.find.mockResolvedValueOnce({ + total: 0, + per_page: 10, + page: 1, + saved_objects: [], + }); + + savedObjectsRepository.find.mockResolvedValueOnce({ + total: 1, + per_page: 1, + page: 1, + saved_objects: [ + { + id: '2', + type: 'alert', + attributes: { + alertTypeId: 'myType', + schedule: { interval: '1s' }, + params: { + bar: true, + }, + createdAt: new Date().toISOString(), + actions: [], + executionStatus: { + status: 'ok', + lastExecutionDate, + }, + }, + score: 1, + references: [], + }, + ], + }); + const result = await getHealth(savedObjectsRepository); + expect(result).toStrictEqual({ + executionHealth: { + status: HealthStatus.Warning, + timestamp: lastExecutionDateError, + }, + readHealth: { + status: HealthStatus.OK, + timestamp: lastExecutionDate, + }, + decryptionHealth: { + status: HealthStatus.OK, + timestamp: lastExecutionDate, + }, + }); + }); +}); diff --git a/x-pack/plugins/alerts/server/health/get_health.ts b/x-pack/plugins/alerts/server/health/get_health.ts new file mode 100644 index 0000000000000..b7b4582aa8d10 --- /dev/null +++ b/x-pack/plugins/alerts/server/health/get_health.ts @@ -0,0 +1,97 @@ +/* + * 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 { ISavedObjectsRepository } from 'src/core/server'; +import { AlertsHealth, HealthStatus, RawAlert, AlertExecutionStatusErrorReasons } from '../types'; + +export const getHealth = async ( + internalSavedObjectsRepository: ISavedObjectsRepository +): Promise => { + const healthStatuses = { + decryptionHealth: { + status: HealthStatus.OK, + timestamp: '', + }, + executionHealth: { + status: HealthStatus.OK, + timestamp: '', + }, + readHealth: { + status: HealthStatus.OK, + timestamp: '', + }, + }; + + const { saved_objects: decryptErrorData } = await internalSavedObjectsRepository.find({ + filter: `alert.attributes.executionStatus.status:error and alert.attributes.executionStatus.error.reason:${AlertExecutionStatusErrorReasons.Decrypt}`, + fields: ['executionStatus'], + type: 'alert', + sortField: 'executionStatus.lastExecutionDate', + sortOrder: 'desc', + page: 1, + perPage: 1, + }); + + if (decryptErrorData.length > 0) { + healthStatuses.decryptionHealth = { + status: HealthStatus.Warning, + timestamp: decryptErrorData[0].attributes.executionStatus.lastExecutionDate, + }; + } + + const { saved_objects: executeErrorData } = await internalSavedObjectsRepository.find({ + filter: `alert.attributes.executionStatus.status:error and alert.attributes.executionStatus.error.reason:${AlertExecutionStatusErrorReasons.Execute}`, + fields: ['executionStatus'], + type: 'alert', + sortField: 'executionStatus.lastExecutionDate', + sortOrder: 'desc', + page: 1, + perPage: 1, + }); + + if (executeErrorData.length > 0) { + healthStatuses.executionHealth = { + status: HealthStatus.Warning, + timestamp: executeErrorData[0].attributes.executionStatus.lastExecutionDate, + }; + } + + const { saved_objects: readErrorData } = await internalSavedObjectsRepository.find({ + filter: `alert.attributes.executionStatus.status:error and alert.attributes.executionStatus.error.reason:${AlertExecutionStatusErrorReasons.Read}`, + fields: ['executionStatus'], + type: 'alert', + sortField: 'executionStatus.lastExecutionDate', + sortOrder: 'desc', + page: 1, + perPage: 1, + }); + + if (readErrorData.length > 0) { + healthStatuses.readHealth = { + status: HealthStatus.Warning, + timestamp: readErrorData[0].attributes.executionStatus.lastExecutionDate, + }; + } + + const { saved_objects: noErrorData } = await internalSavedObjectsRepository.find({ + filter: 'not alert.attributes.executionStatus.status:error', + fields: ['executionStatus'], + type: 'alert', + sortField: 'executionStatus.lastExecutionDate', + sortOrder: 'desc', + }); + const lastExecutionDate = + noErrorData.length > 0 + ? noErrorData[0].attributes.executionStatus.lastExecutionDate + : new Date().toISOString(); + + for (const [, statusItem] of Object.entries(healthStatuses)) { + if (statusItem.status === HealthStatus.OK) { + statusItem.timestamp = lastExecutionDate; + } + } + + return healthStatuses; +}; diff --git a/x-pack/plugins/alerts/server/health/get_state.test.ts b/x-pack/plugins/alerts/server/health/get_state.test.ts new file mode 100644 index 0000000000000..86981c486da0f --- /dev/null +++ b/x-pack/plugins/alerts/server/health/get_state.test.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { taskManagerMock } from '../../../task_manager/server/mocks'; +import { getHealthStatusStream } from '.'; +import { TaskStatus } from '../../../task_manager/server'; +import { HealthStatus } from '../types'; + +describe('getHealthStatusStream()', () => { + const mockTaskManager = taskManagerMock.createStart(); + + it('should return an object with the "unavailable" level and proper summary of "Alerting framework is unhealthy"', async () => { + mockTaskManager.get.mockReturnValue( + new Promise((_resolve, _reject) => { + return { + id: 'test', + attempts: 0, + status: TaskStatus.Running, + version: '123', + runAt: new Date(), + scheduledAt: new Date(), + startedAt: new Date(), + retryAt: new Date(Date.now() + 5 * 60 * 1000), + state: { + runs: 1, + health_status: HealthStatus.Warning, + }, + taskType: 'alerting:alerting_health_check', + params: { + alertId: '1', + }, + ownerId: null, + }; + }) + ); + getHealthStatusStream(mockTaskManager).subscribe( + (val: { level: Readonly; summary: string }) => { + expect(val.level).toBe(false); + } + ); + }); + + it('should return an object with the "available" level and proper summary of "Alerting framework is healthy"', async () => { + mockTaskManager.get.mockReturnValue( + new Promise((_resolve, _reject) => { + return { + id: 'test', + attempts: 0, + status: TaskStatus.Running, + version: '123', + runAt: new Date(), + scheduledAt: new Date(), + startedAt: new Date(), + retryAt: new Date(Date.now() + 5 * 60 * 1000), + state: { + runs: 1, + health_status: HealthStatus.OK, + }, + taskType: 'alerting:alerting_health_check', + params: { + alertId: '1', + }, + ownerId: null, + }; + }) + ); + getHealthStatusStream(mockTaskManager).subscribe( + (val: { level: Readonly; summary: string }) => { + expect(val.level).toBe(true); + } + ); + }); +}); diff --git a/x-pack/plugins/alerts/server/health/get_state.ts b/x-pack/plugins/alerts/server/health/get_state.ts new file mode 100644 index 0000000000000..476456ecad88a --- /dev/null +++ b/x-pack/plugins/alerts/server/health/get_state.ts @@ -0,0 +1,73 @@ +/* + * 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 { interval, Observable } from 'rxjs'; +import { catchError, switchMap } from 'rxjs/operators'; +import { ServiceStatus, ServiceStatusLevels } from '../../../../../src/core/server'; +import { TaskManagerStartContract } from '../../../task_manager/server'; +import { HEALTH_TASK_ID } from './task'; +import { HealthStatus } from '../types'; + +async function getLatestTaskState(taskManager: TaskManagerStartContract) { + try { + const result = await taskManager.get(HEALTH_TASK_ID); + return result; + } catch (err) { + const errMessage = err && err.message ? err.message : err.toString(); + if (!errMessage.includes('NotInitialized')) { + throw err; + } + } + + return null; +} + +const LEVEL_SUMMARY = { + [ServiceStatusLevels.available.toString()]: i18n.translate( + 'xpack.alerts.server.healthStatus.available', + { + defaultMessage: 'Alerting framework is available', + } + ), + [ServiceStatusLevels.degraded.toString()]: i18n.translate( + 'xpack.alerts.server.healthStatus.degraded', + { + defaultMessage: 'Alerting framework is degraded', + } + ), + [ServiceStatusLevels.unavailable.toString()]: i18n.translate( + 'xpack.alerts.server.healthStatus.unavailable', + { + defaultMessage: 'Alerting framework is unavailable', + } + ), +}; + +export const getHealthStatusStream = ( + taskManager: TaskManagerStartContract +): Observable> => { + return interval(60000 * 5).pipe( + switchMap(async () => { + const doc = await getLatestTaskState(taskManager); + const level = + doc?.state?.health_status === HealthStatus.OK + ? ServiceStatusLevels.available + : doc?.state?.health_status === HealthStatus.Warning + ? ServiceStatusLevels.degraded + : ServiceStatusLevels.unavailable; + return { + level, + summary: LEVEL_SUMMARY[level.toString()], + }; + }), + catchError(async (error) => ({ + level: ServiceStatusLevels.unavailable, + summary: LEVEL_SUMMARY[ServiceStatusLevels.unavailable.toString()], + meta: { error }, + })) + ); +}; diff --git a/x-pack/plugins/alerts/server/health/index.ts b/x-pack/plugins/alerts/server/health/index.ts new file mode 100644 index 0000000000000..730c4596aa550 --- /dev/null +++ b/x-pack/plugins/alerts/server/health/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { getHealthStatusStream } from './get_state'; +export { scheduleAlertingHealthCheck, initializeAlertingHealth } from './task'; diff --git a/x-pack/plugins/alerts/server/health/task.ts b/x-pack/plugins/alerts/server/health/task.ts new file mode 100644 index 0000000000000..6ea01a1083c13 --- /dev/null +++ b/x-pack/plugins/alerts/server/health/task.ts @@ -0,0 +1,94 @@ +/* + * 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 { CoreStart, Logger } from 'kibana/server'; +import { + RunContext, + TaskManagerSetupContract, + TaskManagerStartContract, +} from '../../../task_manager/server'; +import { AlertsConfig } from '../config'; +import { AlertingPluginsStart } from '../plugin'; +import { HealthStatus } from '../types'; +import { getHealth } from './get_health'; + +export const HEALTH_TASK_TYPE = 'alerting_health_check'; + +export const HEALTH_TASK_ID = `Alerting-${HEALTH_TASK_TYPE}`; + +export function initializeAlertingHealth( + logger: Logger, + taskManager: TaskManagerSetupContract, + coreStartServices: Promise<[CoreStart, AlertingPluginsStart, unknown]> +) { + registerAlertingHealthCheckTask(logger, taskManager, coreStartServices); +} + +export async function scheduleAlertingHealthCheck( + logger: Logger, + config: Promise, + taskManager: TaskManagerStartContract +) { + try { + const interval = (await config).healthCheck.interval; + await taskManager.ensureScheduled({ + id: HEALTH_TASK_ID, + taskType: HEALTH_TASK_TYPE, + schedule: { + interval, + }, + state: {}, + params: {}, + }); + } catch (e) { + logger.debug(`Error scheduling task, received ${e.message}`); + } +} + +function registerAlertingHealthCheckTask( + logger: Logger, + taskManager: TaskManagerSetupContract, + coreStartServices: Promise<[CoreStart, AlertingPluginsStart, unknown]> +) { + taskManager.registerTaskDefinitions({ + [HEALTH_TASK_TYPE]: { + title: 'Alerting framework health check task', + createTaskRunner: healthCheckTaskRunner(logger, coreStartServices), + }, + }); +} + +export function healthCheckTaskRunner( + logger: Logger, + coreStartServices: Promise<[CoreStart, AlertingPluginsStart, unknown]> +) { + return ({ taskInstance }: RunContext) => { + const { state } = taskInstance; + return { + async run() { + try { + const alertingHealthStatus = await getHealth( + (await coreStartServices)[0].savedObjects.createInternalRepository(['alert']) + ); + return { + state: { + runs: (state.runs || 0) + 1, + health_status: alertingHealthStatus.decryptionHealth.status, + }, + }; + } catch (errMsg) { + logger.warn(`Error executing alerting health check task: ${errMsg}`); + return { + state: { + runs: (state.runs || 0) + 1, + health_status: HealthStatus.Error, + }, + }; + } + }, + }; + }; +} diff --git a/x-pack/plugins/alerts/server/index.ts b/x-pack/plugins/alerts/server/index.ts index 1e442c5196cf2..64e585da5c654 100644 --- a/x-pack/plugins/alerts/server/index.ts +++ b/x-pack/plugins/alerts/server/index.ts @@ -5,8 +5,10 @@ */ import type { PublicMethodsOf } from '@kbn/utility-types'; import { AlertsClient as AlertsClientClass } from './alerts_client'; -import { PluginInitializerContext } from '../../../../src/core/server'; +import { PluginConfigDescriptor, PluginInitializerContext } from '../../../../src/core/server'; import { AlertingPlugin } from './plugin'; +import { configSchema } from './config'; +import { AlertsConfigType } from './types'; export type AlertsClient = PublicMethodsOf; @@ -30,3 +32,7 @@ export { AlertInstance } from './alert_instance'; export { parseDuration } from './lib'; export const plugin = (initContext: PluginInitializerContext) => new AlertingPlugin(initContext); + +export const config: PluginConfigDescriptor = { + schema: configSchema, +}; diff --git a/x-pack/plugins/alerts/server/lib/alert_execution_status.test.ts b/x-pack/plugins/alerts/server/lib/alert_execution_status.test.ts index 3372d19cd4090..bb24ab034d0dd 100644 --- a/x-pack/plugins/alerts/server/lib/alert_execution_status.test.ts +++ b/x-pack/plugins/alerts/server/lib/alert_execution_status.test.ts @@ -57,7 +57,9 @@ describe('AlertExecutionStatus', () => { }); test('error with a reason', () => { - const status = executionStatusFromError(new ErrorWithReason('execute', new Error('hoo!'))); + const status = executionStatusFromError( + new ErrorWithReason(AlertExecutionStatusErrorReasons.Execute, new Error('hoo!')) + ); expect(status.status).toBe('error'); expect(status.error).toMatchInlineSnapshot(` Object { @@ -71,7 +73,7 @@ describe('AlertExecutionStatus', () => { describe('alertExecutionStatusToRaw()', () => { const date = new Date('2020-09-03T16:26:58Z'); const status = 'ok'; - const reason: AlertExecutionStatusErrorReasons = 'decrypt'; + const reason = AlertExecutionStatusErrorReasons.Decrypt; const error = { reason, message: 'wops' }; test('status without an error', () => { @@ -102,7 +104,7 @@ describe('AlertExecutionStatus', () => { describe('alertExecutionStatusFromRaw()', () => { const date = new Date('2020-09-03T16:26:58Z').toISOString(); const status = 'active'; - const reason: AlertExecutionStatusErrorReasons = 'execute'; + const reason = AlertExecutionStatusErrorReasons.Execute; const error = { reason, message: 'wops' }; test('no input', () => { diff --git a/x-pack/plugins/alerts/server/lib/error_with_reason.test.ts b/x-pack/plugins/alerts/server/lib/error_with_reason.test.ts index f31f584400308..eff935966345f 100644 --- a/x-pack/plugins/alerts/server/lib/error_with_reason.test.ts +++ b/x-pack/plugins/alerts/server/lib/error_with_reason.test.ts @@ -5,20 +5,21 @@ */ import { ErrorWithReason, getReasonFromError, isErrorWithReason } from './error_with_reason'; +import { AlertExecutionStatusErrorReasons } from '../types'; describe('ErrorWithReason', () => { const plainError = new Error('well, actually'); - const errorWithReason = new ErrorWithReason('decrypt', plainError); + const errorWithReason = new ErrorWithReason(AlertExecutionStatusErrorReasons.Decrypt, plainError); test('ErrorWithReason class', () => { expect(errorWithReason.message).toBe(plainError.message); expect(errorWithReason.error).toBe(plainError); - expect(errorWithReason.reason).toBe('decrypt'); + expect(errorWithReason.reason).toBe(AlertExecutionStatusErrorReasons.Decrypt); }); test('getReasonFromError()', () => { expect(getReasonFromError(plainError)).toBe('unknown'); - expect(getReasonFromError(errorWithReason)).toBe('decrypt'); + expect(getReasonFromError(errorWithReason)).toBe(AlertExecutionStatusErrorReasons.Decrypt); }); test('isErrorWithReason()', () => { diff --git a/x-pack/plugins/alerts/server/lib/error_with_reason.ts b/x-pack/plugins/alerts/server/lib/error_with_reason.ts index 29eb666e64427..a732b44ef2238 100644 --- a/x-pack/plugins/alerts/server/lib/error_with_reason.ts +++ b/x-pack/plugins/alerts/server/lib/error_with_reason.ts @@ -21,7 +21,7 @@ export function getReasonFromError(error: Error): AlertExecutionStatusErrorReaso if (isErrorWithReason(error)) { return error.reason; } - return 'unknown'; + return AlertExecutionStatusErrorReasons.Unknown; } export function isErrorWithReason(error: Error | ErrorWithReason): error is ErrorWithReason { diff --git a/x-pack/plugins/alerts/server/lib/is_alert_not_found_error.test.ts b/x-pack/plugins/alerts/server/lib/is_alert_not_found_error.test.ts index b570957d82de4..ab21dc77fa251 100644 --- a/x-pack/plugins/alerts/server/lib/is_alert_not_found_error.test.ts +++ b/x-pack/plugins/alerts/server/lib/is_alert_not_found_error.test.ts @@ -8,6 +8,7 @@ import { isAlertSavedObjectNotFoundError } from './is_alert_not_found_error'; import { ErrorWithReason } from './error_with_reason'; import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; import uuid from 'uuid'; +import { AlertExecutionStatusErrorReasons } from '../types'; describe('isAlertSavedObjectNotFoundError', () => { const id = uuid.v4(); @@ -25,7 +26,7 @@ describe('isAlertSavedObjectNotFoundError', () => { }); test('identifies SavedObjects Not Found errors wrapped in an ErrorWithReason', () => { - const error = new ErrorWithReason('read', errorSONF); + const error = new ErrorWithReason(AlertExecutionStatusErrorReasons.Read, errorSONF); expect(isAlertSavedObjectNotFoundError(error, id)).toBe(true); }); }); diff --git a/x-pack/plugins/alerts/server/mocks.ts b/x-pack/plugins/alerts/server/mocks.ts index 05d64bdbb77f4..cfae4c650bd42 100644 --- a/x-pack/plugins/alerts/server/mocks.ts +++ b/x-pack/plugins/alerts/server/mocks.ts @@ -25,6 +25,7 @@ const createStartMock = () => { const mock: jest.Mocked = { listTypes: jest.fn(), getAlertsClientWithRequest: jest.fn().mockResolvedValue(alertsClientMock.create()), + getFrameworkHealth: jest.fn(), }; return mock; }; diff --git a/x-pack/plugins/alerts/server/plugin.test.ts b/x-pack/plugins/alerts/server/plugin.test.ts index b13a1c62f6602..715fbc6aeed45 100644 --- a/x-pack/plugins/alerts/server/plugin.test.ts +++ b/x-pack/plugins/alerts/server/plugin.test.ts @@ -5,7 +5,7 @@ */ import { AlertingPlugin, AlertingPluginsSetup, AlertingPluginsStart } from './plugin'; -import { coreMock } from '../../../../src/core/server/mocks'; +import { coreMock, statusServiceMock } from '../../../../src/core/server/mocks'; import { licensingMock } from '../../licensing/server/mocks'; import { encryptedSavedObjectsMock } from '../../encrypted_saved_objects/server/mocks'; import { taskManagerMock } from '../../task_manager/server/mocks'; @@ -13,15 +13,21 @@ import { eventLogServiceMock } from '../../event_log/server/event_log_service.mo import { KibanaRequest, CoreSetup } from 'kibana/server'; import { featuresPluginMock } from '../../features/server/mocks'; import { KibanaFeature } from '../../features/server'; +import { AlertsConfig } from './config'; describe('Alerting Plugin', () => { describe('setup()', () => { it('should log warning when Encrypted Saved Objects plugin is using an ephemeral encryption key', async () => { - const context = coreMock.createPluginInitializerContext(); + const context = coreMock.createPluginInitializerContext({ + healthCheck: { + interval: '5m', + }, + }); const plugin = new AlertingPlugin(context); const coreSetup = coreMock.createSetup(); const encryptedSavedObjectsSetup = encryptedSavedObjectsMock.createSetup(); + const statusMock = statusServiceMock.createSetupContract(); await plugin.setup( ({ ...coreSetup, @@ -29,6 +35,7 @@ describe('Alerting Plugin', () => { ...coreSetup.http, route: jest.fn(), }, + status: statusMock, } as unknown) as CoreSetup, ({ licensing: licensingMock.createSetup(), @@ -38,6 +45,7 @@ describe('Alerting Plugin', () => { } as unknown) as AlertingPluginsSetup ); + expect(statusMock.set).toHaveBeenCalledTimes(1); expect(encryptedSavedObjectsSetup.usingEphemeralEncryptionKey).toEqual(true); expect(context.logger.get().warn).toHaveBeenCalledWith( 'APIs are disabled due to the Encrypted Saved Objects plugin using an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in kibana.yml.' @@ -55,7 +63,11 @@ describe('Alerting Plugin', () => { */ describe('getAlertsClientWithRequest()', () => { it('throws error when encryptedSavedObjects plugin has usingEphemeralEncryptionKey set to true', async () => { - const context = coreMock.createPluginInitializerContext(); + const context = coreMock.createPluginInitializerContext({ + healthCheck: { + interval: '5m', + }, + }); const plugin = new AlertingPlugin(context); const coreSetup = coreMock.createSetup(); @@ -98,7 +110,11 @@ describe('Alerting Plugin', () => { }); it(`doesn't throw error when encryptedSavedObjects plugin has usingEphemeralEncryptionKey set to false`, async () => { - const context = coreMock.createPluginInitializerContext(); + const context = coreMock.createPluginInitializerContext({ + healthCheck: { + interval: '5m', + }, + }); const plugin = new AlertingPlugin(context); const coreSetup = coreMock.createSetup(); diff --git a/x-pack/plugins/alerts/server/plugin.ts b/x-pack/plugins/alerts/server/plugin.ts index 75873a2845c15..1fa89606a76fc 100644 --- a/x-pack/plugins/alerts/server/plugin.ts +++ b/x-pack/plugins/alerts/server/plugin.ts @@ -6,6 +6,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { first, map } from 'rxjs/operators'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { combineLatest } from 'rxjs'; import { SecurityPluginSetup } from '../../security/server'; import { EncryptedSavedObjectsPluginSetup, @@ -30,6 +31,8 @@ import { SharedGlobalConfig, ElasticsearchServiceStart, ILegacyClusterClient, + StatusServiceSetup, + ServiceStatus, } from '../../../../src/core/server'; import { @@ -56,12 +59,19 @@ import { PluginSetupContract as ActionsPluginSetupContract, PluginStartContract as ActionsPluginStartContract, } from '../../actions/server'; -import { Services } from './types'; +import { AlertsHealth, Services } from './types'; import { registerAlertsUsageCollector } from './usage'; import { initializeAlertingTelemetry, scheduleAlertingTelemetry } from './usage/task'; import { IEventLogger, IEventLogService, IEventLogClientService } from '../../event_log/server'; import { PluginStartContract as FeaturesPluginStart } from '../../features/server'; import { setupSavedObjects } from './saved_objects'; +import { + getHealthStatusStream, + scheduleAlertingHealthCheck, + initializeAlertingHealth, +} from './health'; +import { AlertsConfig } from './config'; +import { getHealth } from './health/get_health'; export const EVENT_LOG_PROVIDER = 'alerting'; export const EVENT_LOG_ACTIONS = { @@ -78,6 +88,7 @@ export interface PluginSetupContract { export interface PluginStartContract { listTypes: AlertTypeRegistry['list']; getAlertsClientWithRequest(request: KibanaRequest): PublicMethodsOf; + getFrameworkHealth: () => Promise; } export interface AlertingPluginsSetup { @@ -89,6 +100,7 @@ export interface AlertingPluginsSetup { spaces?: SpacesPluginSetup; usageCollection?: UsageCollectionSetup; eventLog: IEventLogService; + statusService: StatusServiceSetup; } export interface AlertingPluginsStart { actions: ActionsPluginStartContract; @@ -99,6 +111,7 @@ export interface AlertingPluginsStart { } export class AlertingPlugin { + private readonly config: Promise; private readonly logger: Logger; private alertTypeRegistry?: AlertTypeRegistry; private readonly taskRunnerFactory: TaskRunnerFactory; @@ -115,6 +128,7 @@ export class AlertingPlugin { private eventLogger?: IEventLogger; constructor(initializerContext: PluginInitializerContext) { + this.config = initializerContext.config.create().pipe(first()).toPromise(); this.logger = initializerContext.logger.get('plugins', 'alerting'); this.taskRunnerFactory = new TaskRunnerFactory(); this.alertsClientFactory = new AlertsClientFactory(); @@ -186,6 +200,25 @@ export class AlertingPlugin { }); } + core.getStartServices().then(async ([, startPlugins]) => { + core.status.set( + combineLatest([ + core.status.derivedStatus$, + getHealthStatusStream(startPlugins.taskManager), + ]).pipe( + map(([derivedStatus, healthStatus]) => { + if (healthStatus.level > derivedStatus.level) { + return healthStatus as ServiceStatus; + } else { + return derivedStatus; + } + }) + ) + ); + }); + + initializeAlertingHealth(this.logger, plugins.taskManager, core.getStartServices()); + core.http.registerRouteHandlerContext('alerting', this.createRouteHandlerContext(core)); // Routes @@ -275,10 +308,13 @@ export class AlertingPlugin { }); scheduleAlertingTelemetry(this.telemetryLogger, plugins.taskManager); + scheduleAlertingHealthCheck(this.logger, this.config, plugins.taskManager); return { listTypes: alertTypeRegistry!.list.bind(this.alertTypeRegistry!), getAlertsClientWithRequest, + getFrameworkHealth: async () => + await getHealth(core.savedObjects.createInternalRepository(['alert'])), }; } @@ -293,6 +329,8 @@ export class AlertingPlugin { return alertsClientFactory!.create(request, savedObjects); }, listTypes: alertTypeRegistry!.list.bind(alertTypeRegistry!), + getFrameworkHealth: async () => + await getHealth(savedObjects.createInternalRepository(['alert'])), }; }; }; diff --git a/x-pack/plugins/alerts/server/routes/_mock_handler_arguments.ts b/x-pack/plugins/alerts/server/routes/_mock_handler_arguments.ts index 3d13fc65ab260..b3f407b20c142 100644 --- a/x-pack/plugins/alerts/server/routes/_mock_handler_arguments.ts +++ b/x-pack/plugins/alerts/server/routes/_mock_handler_arguments.ts @@ -14,7 +14,7 @@ import { identity } from 'lodash'; import type { MethodKeysOf } from '@kbn/utility-types'; import { httpServerMock } from '../../../../../src/core/server/mocks'; import { alertsClientMock, AlertsClientMock } from '../alerts_client.mock'; -import { AlertType } from '../../common'; +import { AlertsHealth, AlertType } from '../../common'; import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; export function mockHandlerArguments( @@ -22,10 +22,13 @@ export function mockHandlerArguments( alertsClient = alertsClientMock.create(), listTypes: listTypesRes = [], esClient = elasticsearchServiceMock.createLegacyClusterClient(), + getFrameworkHealth, }: { alertsClient?: AlertsClientMock; listTypes?: AlertType[]; esClient?: jest.Mocked; + getFrameworkHealth?: jest.MockInstance, []> & + (() => Promise); }, req: unknown, res?: Array> @@ -39,6 +42,7 @@ export function mockHandlerArguments( getAlertsClient() { return alertsClient || alertsClientMock.create(); }, + getFrameworkHealth, }, } as unknown) as RequestHandlerContext, req as KibanaRequest, diff --git a/x-pack/plugins/alerts/server/routes/health.test.ts b/x-pack/plugins/alerts/server/routes/health.test.ts index ce782dbd631a5..d1967c6dd9bf8 100644 --- a/x-pack/plugins/alerts/server/routes/health.test.ts +++ b/x-pack/plugins/alerts/server/routes/health.test.ts @@ -11,13 +11,34 @@ import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; import { verifyApiAccess } from '../lib/license_api_access'; import { mockLicenseState } from '../lib/license_state.mock'; import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks'; +import { alertsClientMock } from '../alerts_client.mock'; +import { HealthStatus } from '../types'; +import { alertsMock } from '../mocks'; +const alertsClient = alertsClientMock.create(); jest.mock('../lib/license_api_access.ts', () => ({ verifyApiAccess: jest.fn(), })); +const alerting = alertsMock.createStart(); + +const currentDate = new Date().toISOString(); beforeEach(() => { jest.resetAllMocks(); + alerting.getFrameworkHealth.mockResolvedValue({ + decryptionHealth: { + status: HealthStatus.OK, + timestamp: currentDate, + }, + executionHealth: { + status: HealthStatus.OK, + timestamp: currentDate, + }, + readHealth: { + status: HealthStatus.OK, + timestamp: currentDate, + }, + }); }); describe('healthRoute', () => { @@ -46,7 +67,7 @@ describe('healthRoute', () => { const esClient = elasticsearchServiceMock.createLegacyClusterClient(); esClient.callAsInternalUser.mockReturnValue(Promise.resolve({})); - const [context, req, res] = mockHandlerArguments({ esClient }, {}, ['ok']); + const [context, req, res] = mockHandlerArguments({ esClient, alertsClient }, {}, ['ok']); await handler(context, req, res); @@ -75,16 +96,32 @@ describe('healthRoute', () => { const esClient = elasticsearchServiceMock.createLegacyClusterClient(); esClient.callAsInternalUser.mockReturnValue(Promise.resolve({})); - const [context, req, res] = mockHandlerArguments({ esClient }, {}, ['ok']); + const [context, req, res] = mockHandlerArguments( + { esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, + {}, + ['ok'] + ); - expect(await handler(context, req, res)).toMatchInlineSnapshot(` - Object { - "body": Object { - "hasPermanentEncryptionKey": false, - "isSufficientlySecure": true, + expect(await handler(context, req, res)).toStrictEqual({ + body: { + alertingFrameworkHeath: { + decryptionHealth: { + status: HealthStatus.OK, + timestamp: currentDate, + }, + executionHealth: { + status: HealthStatus.OK, + timestamp: currentDate, + }, + readHealth: { + status: HealthStatus.OK, + timestamp: currentDate, + }, }, - } - `); + hasPermanentEncryptionKey: false, + isSufficientlySecure: true, + }, + }); }); it('evaluates missing security info from the usage api to mean that the security plugin is disbled', async () => { @@ -99,16 +136,32 @@ describe('healthRoute', () => { const esClient = elasticsearchServiceMock.createLegacyClusterClient(); esClient.callAsInternalUser.mockReturnValue(Promise.resolve({})); - const [context, req, res] = mockHandlerArguments({ esClient }, {}, ['ok']); + const [context, req, res] = mockHandlerArguments( + { esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, + {}, + ['ok'] + ); - expect(await handler(context, req, res)).toMatchInlineSnapshot(` - Object { - "body": Object { - "hasPermanentEncryptionKey": true, - "isSufficientlySecure": true, + expect(await handler(context, req, res)).toStrictEqual({ + body: { + alertingFrameworkHeath: { + decryptionHealth: { + status: HealthStatus.OK, + timestamp: currentDate, + }, + executionHealth: { + status: HealthStatus.OK, + timestamp: currentDate, + }, + readHealth: { + status: HealthStatus.OK, + timestamp: currentDate, + }, }, - } - `); + hasPermanentEncryptionKey: true, + isSufficientlySecure: true, + }, + }); }); it('evaluates missing security http info from the usage api to mean that the security plugin is disbled', async () => { @@ -123,16 +176,32 @@ describe('healthRoute', () => { const esClient = elasticsearchServiceMock.createLegacyClusterClient(); esClient.callAsInternalUser.mockReturnValue(Promise.resolve({ security: {} })); - const [context, req, res] = mockHandlerArguments({ esClient }, {}, ['ok']); + const [context, req, res] = mockHandlerArguments( + { esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, + {}, + ['ok'] + ); - expect(await handler(context, req, res)).toMatchInlineSnapshot(` - Object { - "body": Object { - "hasPermanentEncryptionKey": true, - "isSufficientlySecure": true, + expect(await handler(context, req, res)).toStrictEqual({ + body: { + alertingFrameworkHeath: { + decryptionHealth: { + status: HealthStatus.OK, + timestamp: currentDate, + }, + executionHealth: { + status: HealthStatus.OK, + timestamp: currentDate, + }, + readHealth: { + status: HealthStatus.OK, + timestamp: currentDate, + }, }, - } - `); + hasPermanentEncryptionKey: true, + isSufficientlySecure: true, + }, + }); }); it('evaluates security enabled, and missing ssl info from the usage api to mean that the user cannot generate keys', async () => { @@ -147,16 +216,32 @@ describe('healthRoute', () => { const esClient = elasticsearchServiceMock.createLegacyClusterClient(); esClient.callAsInternalUser.mockReturnValue(Promise.resolve({ security: { enabled: true } })); - const [context, req, res] = mockHandlerArguments({ esClient }, {}, ['ok']); + const [context, req, res] = mockHandlerArguments( + { esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, + {}, + ['ok'] + ); - expect(await handler(context, req, res)).toMatchInlineSnapshot(` - Object { - "body": Object { - "hasPermanentEncryptionKey": true, - "isSufficientlySecure": false, + expect(await handler(context, req, res)).toStrictEqual({ + body: { + alertingFrameworkHeath: { + decryptionHealth: { + status: HealthStatus.OK, + timestamp: currentDate, + }, + executionHealth: { + status: HealthStatus.OK, + timestamp: currentDate, + }, + readHealth: { + status: HealthStatus.OK, + timestamp: currentDate, + }, }, - } - `); + hasPermanentEncryptionKey: true, + isSufficientlySecure: false, + }, + }); }); it('evaluates security enabled, SSL info present but missing http info from the usage api to mean that the user cannot generate keys', async () => { @@ -173,16 +258,32 @@ describe('healthRoute', () => { Promise.resolve({ security: { enabled: true, ssl: {} } }) ); - const [context, req, res] = mockHandlerArguments({ esClient }, {}, ['ok']); + const [context, req, res] = mockHandlerArguments( + { esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, + {}, + ['ok'] + ); - expect(await handler(context, req, res)).toMatchInlineSnapshot(` - Object { - "body": Object { - "hasPermanentEncryptionKey": true, - "isSufficientlySecure": false, + expect(await handler(context, req, res)).toStrictEqual({ + body: { + alertingFrameworkHeath: { + decryptionHealth: { + status: HealthStatus.OK, + timestamp: currentDate, + }, + executionHealth: { + status: HealthStatus.OK, + timestamp: currentDate, + }, + readHealth: { + status: HealthStatus.OK, + timestamp: currentDate, + }, }, - } - `); + hasPermanentEncryptionKey: true, + isSufficientlySecure: false, + }, + }); }); it('evaluates security and tls enabled to mean that the user can generate keys', async () => { @@ -199,15 +300,31 @@ describe('healthRoute', () => { Promise.resolve({ security: { enabled: true, ssl: { http: { enabled: true } } } }) ); - const [context, req, res] = mockHandlerArguments({ esClient }, {}, ['ok']); + const [context, req, res] = mockHandlerArguments( + { esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth }, + {}, + ['ok'] + ); - expect(await handler(context, req, res)).toMatchInlineSnapshot(` - Object { - "body": Object { - "hasPermanentEncryptionKey": true, - "isSufficientlySecure": true, + expect(await handler(context, req, res)).toStrictEqual({ + body: { + alertingFrameworkHeath: { + decryptionHealth: { + status: HealthStatus.OK, + timestamp: currentDate, + }, + executionHealth: { + status: HealthStatus.OK, + timestamp: currentDate, + }, + readHealth: { + status: HealthStatus.OK, + timestamp: currentDate, + }, }, - } - `); + hasPermanentEncryptionKey: true, + isSufficientlySecure: true, + }, + }); }); }); diff --git a/x-pack/plugins/alerts/server/routes/health.ts b/x-pack/plugins/alerts/server/routes/health.ts index b66e28b24e8a7..bfd5b1e272287 100644 --- a/x-pack/plugins/alerts/server/routes/health.ts +++ b/x-pack/plugins/alerts/server/routes/health.ts @@ -43,6 +43,9 @@ export function healthRoute( res: KibanaResponseFactory ): Promise { verifyApiAccess(licenseState); + if (!context.alerting) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); + } try { const { security: { @@ -57,9 +60,12 @@ export function healthRoute( path: '/_xpack/usage', }); + const alertingFrameworkHeath = await context.alerting.getFrameworkHealth(); + const frameworkHealth: AlertingFrameworkHealth = { isSufficientlySecure: !isSecurityEnabled || (isSecurityEnabled && isTLSEnabled), hasPermanentEncryptionKey: !encryptedSavedObjects.usingEphemeralEncryptionKey, + alertingFrameworkHeath, }; return res.ok({ diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.ts index 6a49f67268d69..86bf7006e8d09 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.ts @@ -28,6 +28,7 @@ import { AlertExecutorOptions, SanitizedAlert, AlertExecutionStatus, + AlertExecutionStatusErrorReasons, } from '../types'; import { promiseResult, map, Resultable, asOk, asErr, resolveErr } from '../lib/result_type'; import { taskInstanceToAlertTaskInstance } from './alert_task_instance'; @@ -211,7 +212,7 @@ export class TaskRunner { event.event = event.event || {}; event.event.outcome = 'failure'; eventLogger.logEvent(event); - throw new ErrorWithReason('execute', err); + throw new ErrorWithReason(AlertExecutionStatusErrorReasons.Execute, err); } eventLogger.stopTiming(event); @@ -288,7 +289,7 @@ export class TaskRunner { try { apiKey = await this.getApiKeyForAlertPermissions(alertId, spaceId); } catch (err) { - throw new ErrorWithReason('decrypt', err); + throw new ErrorWithReason(AlertExecutionStatusErrorReasons.Decrypt, err); } const [services, alertsClient] = this.getServicesWithSpaceLevelPermissions(spaceId, apiKey); @@ -298,7 +299,7 @@ export class TaskRunner { try { alert = await alertsClient.get({ id: alertId }); } catch (err) { - throw new ErrorWithReason('read', err); + throw new ErrorWithReason(AlertExecutionStatusErrorReasons.Read, err); } return { diff --git a/x-pack/plugins/alerts/server/types.ts b/x-pack/plugins/alerts/server/types.ts index 42eef9bba10e5..9226461f6e30a 100644 --- a/x-pack/plugins/alerts/server/types.ts +++ b/x-pack/plugins/alerts/server/types.ts @@ -27,6 +27,7 @@ import { AlertInstanceState, AlertExecutionStatuses, AlertExecutionStatusErrorReasons, + AlertsHealth, } from '../common'; export type WithoutQueryAndParams = Pick>; @@ -39,6 +40,7 @@ declare module 'src/core/server' { alerting?: { getAlertsClient: () => AlertsClient; listTypes: AlertTypeRegistry['list']; + getFrameworkHealth: () => Promise; }; } } @@ -172,4 +174,10 @@ export interface AlertingPlugin { start: PluginStartContract; } +export interface AlertsConfigType { + healthCheck: { + interval: string; + }; +} + export type AlertTypeRegistry = PublicMethodsOf; diff --git a/x-pack/plugins/apm/e2e/cypress/integration/apm.feature b/x-pack/plugins/apm/e2e/cypress/integration/apm.feature index 285615108266b..494a6b5fadb5b 100644 --- a/x-pack/plugins/apm/e2e/cypress/integration/apm.feature +++ b/x-pack/plugins/apm/e2e/cypress/integration/apm.feature @@ -3,5 +3,4 @@ Feature: APM Scenario: Transaction duration charts Given a user browses the APM UI application When the user inspects the opbeans-node service - Then should redirect to correct path with correct params - And should have correct y-axis ticks + Then should redirect to correct path with correct params \ No newline at end of file diff --git a/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js b/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js index 72b49bb85b7a5..0ecda7a113de7 100644 --- a/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js +++ b/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js @@ -1,3 +1,3 @@ module.exports = { - __version: '5.5.0', -}; + "__version": "5.4.0" +} diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/apm.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/apm.ts index 50c620dca9ddf..42c2bc7ffd318 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/apm.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/apm.ts @@ -29,16 +29,3 @@ Then(`should redirect to correct path with correct params`, () => { cy.url().should('contain', `/app/apm/services/opbeans-node/transactions`); cy.url().should('contain', `transactionType=request`); }); - -Then(`should have correct y-axis ticks`, () => { - const yAxisTick = - '[data-cy=transaction-duration-charts] .rv-xy-plot__axis--vertical .rv-xy-plot__axis__tick__text'; - - // wait for all loading to finish - cy.get('kbnLoadingIndicator').should('not.be.visible'); - - // literal assertions because snapshot() doesn't retry - cy.get(yAxisTick).eq(2).should('have.text', '55 ms'); - cy.get(yAxisTick).eq(1).should('have.text', '28 ms'); - cy.get(yAxisTick).eq(0).should('have.text', '0 ms'); -}); diff --git a/x-pack/plugins/apm/e2e/package.json b/x-pack/plugins/apm/e2e/package.json deleted file mode 100644 index 5839f4d58537c..0000000000000 --- a/x-pack/plugins/apm/e2e/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "apm-cypress", - "version": "1.0.0", - "main": "index.js", - "license": "MIT", - "scripts": { - "cypress:open": "../../../../node_modules/.bin/cypress open", - "cypress:run": "../../../../node_modules/.bin/cypress run --spec **/*.feature" - } -} \ No newline at end of file diff --git a/x-pack/plugins/apm/e2e/run-e2e.sh b/x-pack/plugins/apm/e2e/run-e2e.sh index 6cdae93aec63b..85ab67bbf9a10 100755 --- a/x-pack/plugins/apm/e2e/run-e2e.sh +++ b/x-pack/plugins/apm/e2e/run-e2e.sh @@ -20,6 +20,8 @@ normal=$(tput sgr0) E2E_DIR="${0%/*}" TMP_DIR="tmp" APM_IT_DIR="tmp/apm-integration-testing" +WAIT_ON_BIN="../../../../node_modules/.bin/wait-on" +CYPRESS_BIN="../../../../node_modules/.bin/cypress" cd ${E2E_DIR} @@ -92,14 +94,6 @@ if [ $? -ne 0 ]; then exit 1 fi -# -# Cypress -################################################## -echo "" # newline -echo "${bold}Cypress (logs: ${E2E_DIR}${TMP_DIR}/e2e-yarn.log)${normal}" -echo "Installing cypress dependencies " -yarn &> ${TMP_DIR}/e2e-yarn.log - # # Static mock data ################################################## @@ -148,7 +142,7 @@ fi echo "" # newline echo "${bold}Waiting for Kibana to start...${normal}" echo "Note: you need to start Kibana manually. Find the instructions at the top." -yarn wait-on -i 500 -w 500 http-get://admin:changeme@localhost:$KIBANA_PORT/api/status > /dev/null +$WAIT_ON_BIN -i 500 -w 500 http-get://admin:changeme@localhost:$KIBANA_PORT/api/status > /dev/null ## Workaround to wait for the http server running ## See: https://github.com/elastic/kibana/issues/66326 @@ -165,7 +159,7 @@ echo "✅ Setup completed successfully. Running tests..." # # run cypress tests ################################################## -yarn cypress run --config pageLoadTimeout=100000,watchForFileChanges=true +$CYPRESS_BIN run --config pageLoadTimeout=100000,watchForFileChanges=true e2e_status=$? # @@ -173,7 +167,7 @@ e2e_status=$? ################################################## echo "${bold}If you want to run the test interactively, run:${normal}" echo "" # newline -echo "cd ${E2E_DIR} && yarn cypress open --config pageLoadTimeout=100000,watchForFileChanges=true" +echo "cd ${E2E_DIR} && ${CYPRESS_BIN} open --config pageLoadTimeout=100000,watchForFileChanges=true" # Report the e2e status at the very end if [ $e2e_status -ne 0 ]; then diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx index e17dd9a9eb038..a17bf7e93e466 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx @@ -4,31 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ +import { + Axis, + Chart, + HistogramBarSeries, + niceTimeFormatter, + Position, + ScaleType, + Settings, + SettingsSpec, + TooltipValue, +} from '@elastic/charts'; import { EuiTitle } from '@elastic/eui'; -import theme from '@elastic/eui/dist/eui_theme_light.json'; -import numeral from '@elastic/numeral'; -import { i18n } from '@kbn/i18n'; import d3 from 'd3'; -import { scaleUtc } from 'd3-scale'; -import { mean } from 'lodash'; import React from 'react'; import { asRelativeDateTimeRange } from '../../../../../common/utils/formatters'; -import { getTimezoneOffsetInMs } from '../../../shared/charts/CustomPlot/getTimezoneOffsetInMs'; -// @ts-expect-error -import Histogram from '../../../shared/charts/Histogram'; -import { EmptyMessage } from '../../../shared/EmptyMessage'; - -interface IBucket { - key: number; - count: number | undefined; -} - -// TODO: cleanup duplication of this in distribution/get_distribution.ts (ErrorDistributionAPIResponse) and transactions/distribution/index.ts (TransactionDistributionAPIResponse) -interface IDistribution { - noHits: boolean; - buckets: IBucket[]; - bucketSize: number; -} +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import type { ErrorDistributionAPIResponse } from '../../../../../server/lib/errors/distribution/get_distribution'; +import { useTheme } from '../../../../hooks/useTheme'; interface FormattedBucket { x0: number; @@ -37,13 +30,9 @@ interface FormattedBucket { } export function getFormattedBuckets( - buckets: IBucket[], + buckets: ErrorDistributionAPIResponse['buckets'], bucketSize: number -): FormattedBucket[] | null { - if (!buckets) { - return null; - } - +): FormattedBucket[] { return buckets.map(({ count, key }) => { return { x0: key, @@ -54,76 +43,66 @@ export function getFormattedBuckets( } interface Props { - distribution: IDistribution; + distribution: ErrorDistributionAPIResponse; title: React.ReactNode; } -const tooltipHeader = (bucket: FormattedBucket) => - asRelativeDateTimeRange(bucket.x0, bucket.x); - export function ErrorDistribution({ distribution, title }: Props) { + const theme = useTheme(); const buckets = getFormattedBuckets( distribution.buckets, distribution.bucketSize ); - if (!buckets) { - return ( - - ); - } - - const averageValue = mean(buckets.map((bucket) => bucket.y)) || 0; const xMin = d3.min(buckets, (d) => d.x0); - const xMax = d3.max(buckets, (d) => d.x); - const tickFormat = scaleUtc().domain([xMin, xMax]).tickFormat(); + const xMax = d3.max(buckets, (d) => d.x0); + + const xFormatter = niceTimeFormatter([xMin, xMax]); + + const tooltipProps: SettingsSpec['tooltip'] = { + headerFormatter: (tooltip: TooltipValue) => { + const serie = buckets.find((bucket) => bucket.x0 === tooltip.value); + if (serie) { + return asRelativeDateTimeRange(serie.x0, serie.x); + } + return `${tooltip.value}`; + }, + }; return (
{title} - bucket.x} - xType="time-utc" - formatX={(value: Date) => { - const time = value.getTime(); - return tickFormat(new Date(time - getTimezoneOffsetInMs(time))); - }} - buckets={buckets} - bucketSize={distribution.bucketSize} - formatYShort={(value: number) => - i18n.translate('xpack.apm.errorGroupDetails.occurrencesShortLabel', { - defaultMessage: '{occCount} occ.', - values: { occCount: value }, - }) - } - formatYLong={(value: number) => - i18n.translate('xpack.apm.errorGroupDetails.occurrencesLongLabel', { - defaultMessage: - '{occCount} {occCount, plural, one {occurrence} other {occurrences}}', - values: { occCount: value }, - }) - } - legends={[ - { - color: theme.euiColorVis1, - // 0a abbreviates large whole numbers with metric prefixes like: 1000 = 1k, 32000 = 32k, 1000000 = 1m - legendValue: numeral(averageValue).format('0a'), - title: i18n.translate('xpack.apm.errorGroupDetails.avgLabel', { - defaultMessage: 'Avg.', - }), - legendClickDisabled: true, - }, - ]} - /> +
+ + + + + + +
); } diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx index 67125d41635a9..bf1bda793179f 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx @@ -4,22 +4,37 @@ * you may not use this file except in compliance with the Elastic License. */ +import { + Axis, + Chart, + ElementClickListener, + GeometryValue, + HistogramBarSeries, + Position, + RectAnnotation, + ScaleType, + Settings, + SettingsSpec, + TooltipValue, + XYChartSeriesIdentifier, +} from '@elastic/charts'; import { EuiIconTip, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import d3 from 'd3'; import { isEmpty } from 'lodash'; import React, { useCallback } from 'react'; import { ValuesType } from 'utility-types'; +import { useTheme } from '../../../../../../observability/public'; import { getDurationFormatter } from '../../../../../common/utils/formatters'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { TransactionDistributionAPIResponse } from '../../../../../server/lib/transactions/distribution'; +import type { TransactionDistributionAPIResponse } from '../../../../../server/lib/transactions/distribution'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { DistributionBucket } from '../../../../../server/lib/transactions/distribution/get_buckets'; +import type { DistributionBucket } from '../../../../../server/lib/transactions/distribution/get_buckets'; import { IUrlParams } from '../../../../context/UrlParamsContext/types'; -// @ts-expect-error -import Histogram from '../../../shared/charts/Histogram'; +import { FETCH_STATUS } from '../../../../hooks/useFetcher'; +import { unit } from '../../../../style/variables'; +import { ChartContainer } from '../../../shared/charts/chart_container'; import { EmptyMessage } from '../../../shared/EmptyMessage'; -import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt'; interface IChartPoint { x0: number; @@ -31,10 +46,10 @@ interface IChartPoint { } export function getFormattedBuckets( - buckets: DistributionBucket[], - bucketSize: number + buckets?: DistributionBucket[], + bucketSize?: number ) { - if (!buckets) { + if (!buckets || !bucketSize) { return []; } @@ -74,7 +89,7 @@ const getFormatYLong = (transactionType: string | undefined) => (t: number) => { 'xpack.apm.transactionDetails.transactionsDurationDistributionChart.requestTypeUnitLongLabel', { defaultMessage: - '{transCount, plural, =0 {# request} one {# request} other {# requests}}', + '{transCount, plural, =0 {request} one {request} other {requests}}', values: { transCount: t, }, @@ -84,7 +99,7 @@ const getFormatYLong = (transactionType: string | undefined) => (t: number) => { 'xpack.apm.transactionDetails.transactionsDurationDistributionChart.transactionTypeUnitLongLabel', { defaultMessage: - '{transCount, plural, =0 {# transaction} one {# transaction} other {# transactions}}', + '{transCount, plural, =0 {transaction} one {transaction} other {transactions}}', values: { transCount: t, }, @@ -95,21 +110,21 @@ const getFormatYLong = (transactionType: string | undefined) => (t: number) => { interface Props { distribution?: TransactionDistributionAPIResponse; urlParams: IUrlParams; - isLoading: boolean; + fetchStatus: FETCH_STATUS; bucketIndex: number; onBucketClick: ( bucket: ValuesType ) => void; } -export function TransactionDistribution(props: Props) { - const { - distribution, - urlParams: { transactionType }, - isLoading, - bucketIndex, - onBucketClick, - } = props; +export function TransactionDistribution({ + distribution, + urlParams: { transactionType }, + fetchStatus, + bucketIndex, + onBucketClick, +}: Props) { + const theme = useTheme(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ const formatYShort = useCallback(getFormatYShort(transactionType), [ @@ -122,12 +137,10 @@ export function TransactionDistribution(props: Props) { ]); // no data in response - if (!distribution || distribution.noHits) { - // only show loading state if there is no data - else show stale data until new data has loaded - if (isLoading) { - return ; - } - + if ( + (!distribution || distribution.noHits) && + fetchStatus !== FETCH_STATUS.LOADING + ) { return ( { - return bucket.key === chartPoint.x0; - }); - - return clickedBucket; - } - const buckets = getFormattedBuckets( - distribution.buckets, - distribution.bucketSize + distribution?.buckets, + distribution?.bucketSize ); - const xMax = d3.max(buckets, (d) => d.x) || 0; + const xMin = d3.min(buckets, (d) => d.x0) || 0; + const xMax = d3.max(buckets, (d) => d.x0) || 0; const timeFormatter = getDurationFormatter(xMax); + const tooltipProps: SettingsSpec['tooltip'] = { + headerFormatter: (tooltip: TooltipValue) => { + const serie = buckets.find((bucket) => bucket.x0 === tooltip.value); + if (serie) { + const xFormatted = timeFormatter(serie.x); + const x0Formatted = timeFormatter(serie.x0); + return `${x0Formatted.value} - ${xFormatted.value} ${xFormatted.unit}`; + } + return `${timeFormatter(tooltip.value)}`; + }, + }; + + const onBarClick: ElementClickListener = (elements) => { + const chartPoint = elements[0][0] as GeometryValue; + const clickedBucket = distribution?.buckets.find((bucket) => { + return bucket.key === chartPoint.x; + }); + if (clickedBucket) { + onBucketClick(clickedBucket); + } + }; + + const selectedBucket = buckets[bucketIndex]; + return (
@@ -181,42 +211,66 @@ export function TransactionDistribution(props: Props) { /> - - { - const clickedBucket = getBucketFromChartPoint(chartPoint); - - if (clickedBucket) { - onBucketClick(clickedBucket); - } - }} - formatX={(time: number) => timeFormatter(time).formatted} - formatYShort={formatYShort} - formatYLong={formatYLong} - verticalLineHover={(point: IChartPoint) => - isEmpty(getBucketFromChartPoint(point)?.samples) - } - backgroundHover={(point: IChartPoint) => - !isEmpty(getBucketFromChartPoint(point)?.samples) - } - tooltipHeader={(point: IChartPoint) => { - const xFormatted = timeFormatter(point.x); - const x0Formatted = timeFormatter(point.x0); - return `${x0Formatted.value} - ${xFormatted.value} ${xFormatted.unit}`; - }} - tooltipFooter={(point: IChartPoint) => - isEmpty(getBucketFromChartPoint(point)?.samples) && - i18n.translate( - 'xpack.apm.transactionDetails.transactionsDurationDistributionChart.noSampleTooltip', - { - defaultMessage: 'No sample available for this bucket', - } - ) - } - /> + + + + {selectedBucket && ( + + )} + timeFormatter(time).formatted} + /> + formatYShort(value)} + /> + value} + minBarHeight={2} + id="transactionDurationDistribution" + name={(series: XYChartSeriesIdentifier) => { + const bucketCount = series.splitAccessors.get( + series.yAccessor + ) as number; + return formatYLong(bucketCount); + }} + splitSeriesAccessors={['y']} + xScaleType={ScaleType.Linear} + yScaleType={ScaleType.Linear} + xAccessor="x0" + yAccessors={['y']} + data={buckets} + color={theme.eui.euiColorVis1} + /> + +
); } diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx index efdd7b1f34221..e4c36b028e55c 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx @@ -52,7 +52,11 @@ export function TransactionDetails({ status: distributionStatus, } = useTransactionDistribution(urlParams); - const { data: transactionChartsData } = useTransactionCharts(); + const { + data: transactionChartsData, + status: transactionChartsStatus, + } = useTransactionCharts(); + const { waterfall, exceedsMax, status: waterfallStatus } = useWaterfall( urlParams ); @@ -121,6 +125,7 @@ export function TransactionDetails({ @@ -131,7 +136,7 @@ export function TransactionDetails({ { diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionOverview.test.tsx b/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionOverview.test.tsx index b7d1b93600a73..c530a7e1489ad 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionOverview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionOverview.test.tsx @@ -4,12 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - fireEvent, - getByText, - queryByLabelText, - render, -} from '@testing-library/react'; +import { fireEvent, getByText, queryByLabelText } from '@testing-library/react'; import { createMemoryHistory } from 'history'; import { CoreStart } from 'kibana/public'; import React from 'react'; @@ -20,7 +15,10 @@ import { UrlParamsProvider } from '../../../context/UrlParamsContext'; import { IUrlParams } from '../../../context/UrlParamsContext/types'; import * as useFetcherHook from '../../../hooks/useFetcher'; import * as useServiceTransactionTypesHook from '../../../hooks/useServiceTransactionTypes'; -import { disableConsoleWarning } from '../../../utils/testHelpers'; +import { + disableConsoleWarning, + renderWithTheme, +} from '../../../utils/testHelpers'; import { fromQuery } from '../../shared/Links/url_helpers'; import { TransactionOverview } from './'; @@ -54,7 +52,7 @@ function setup({ jest.spyOn(useFetcherHook, 'useFetcher').mockReturnValue({} as any); - return render( + return renderWithTheme( diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx index 5444d2d521f37..df9e673ed4847 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx @@ -22,7 +22,7 @@ import React, { useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import { useTrackPageview } from '../../../../../observability/public'; import { Projection } from '../../../../common/projections'; -import { LegacyChartsSyncContextProvider as ChartsSyncContextProvider } from '../../../context/charts_sync_context'; +import { TRANSACTION_PAGE_LOAD } from '../../../../common/transaction_types'; import { IUrlParams } from '../../../context/UrlParamsContext/types'; import { useServiceTransactionTypes } from '../../../hooks/useServiceTransactionTypes'; import { useTransactionCharts } from '../../../hooks/useTransactionCharts'; @@ -33,11 +33,10 @@ import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; import { fromQuery, toQuery } from '../../shared/Links/url_helpers'; import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { TransactionTypeFilter } from '../../shared/LocalUIFilters/TransactionTypeFilter'; +import { Correlations } from '../Correlations'; import { TransactionList } from './TransactionList'; import { useRedirect } from './useRedirect'; -import { TRANSACTION_PAGE_LOAD } from '../../../../common/transaction_types'; import { UserExperienceCallout } from './user_experience_callout'; -import { Correlations } from '../Correlations'; function getRedirectLocation({ urlParams, @@ -83,7 +82,10 @@ export function TransactionOverview({ serviceName }: TransactionOverviewProps) { }) ); - const { data: transactionCharts } = useTransactionCharts(); + const { + data: transactionCharts, + status: transactionChartsStatus, + } = useTransactionCharts(); useTrackPageview({ app: 'apm', path: 'transaction_overview' }); useTrackPageview({ app: 'apm', path: 'transaction_overview', delay: 15000 }); @@ -135,12 +137,11 @@ export function TransactionOverview({ serviceName }: TransactionOverviewProps) { )} - - - + @@ -190,7 +191,7 @@ export function TransactionOverview({ serviceName }: TransactionOverviewProps) { diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx index 342152b572f1e..016ee3daf6b51 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx @@ -11,7 +11,7 @@ import styled from 'styled-components'; import { useTrackPageview } from '../../../../../observability/public'; import { isRumAgentName } from '../../../../common/agent_name'; import { ChartsSyncContextProvider } from '../../../context/charts_sync_context'; -import { ErroneousTransactionsRateChart } from '../../shared/charts/erroneous_transactions_rate_chart'; +import { TransactionErrorRateChart } from '../../shared/charts/transaction_error_rate_chart'; import { ErrorOverviewLink } from '../../shared/Links/apm/ErrorOverviewLink'; import { ServiceMapLink } from '../../shared/Links/apm/ServiceMapLink'; import { TransactionOverviewLink } from '../../shared/Links/apm/TransactionOverviewLink'; @@ -125,19 +125,7 @@ export function ServiceOverview({ {!isRumAgentName(agentName) && ( - - -

- {i18n.translate( - 'xpack.apm.serviceOverview.errorRateChartTitle', - { - defaultMessage: 'Error rate', - } - )} -

-
- -
+
)} diff --git a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx index b908eb8da4d03..05cae589c19fc 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx @@ -4,62 +4,113 @@ * you may not use this file except in compliance with the Elastic License. */ -import { throttle } from 'lodash'; -import React, { useMemo } from 'react'; +import { + AreaSeries, + Axis, + Chart, + niceTimeFormatter, + Placement, + Position, + ScaleType, + Settings, +} from '@elastic/charts'; +import moment from 'moment'; +import React, { useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; import { asPercent } from '../../../../../common/utils/formatters'; -import { useUiTracker } from '../../../../../../observability/public'; -import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; -import { Maybe } from '../../../../../typings/common'; -import { Coordinate, TimeSeries } from '../../../../../typings/timeseries'; +import { TimeSeries } from '../../../../../typings/timeseries'; +import { FETCH_STATUS } from '../../../../hooks/useFetcher'; import { useUrlParams } from '../../../../hooks/useUrlParams'; -import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; -import { getEmptySeries } from '../../charts/CustomPlot/getEmptySeries'; -import { TransactionLineChart } from '../../charts/TransactionCharts/TransactionLineChart'; +import { useChartsSync as useChartsSync2 } from '../../../../hooks/use_charts_sync'; +import { unit } from '../../../../style/variables'; +import { Annotations } from '../../charts/annotations'; +import { ChartContainer } from '../../charts/chart_container'; +import { onBrushEnd } from '../../charts/helper/helper'; + +const XY_HEIGHT = unit * 16; interface Props { - timeseries: TimeSeries[]; - noHits: boolean; + fetchStatus: FETCH_STATUS; + timeseries?: TimeSeries[]; } -const tickFormatY = (y: Maybe) => { - return asPercent(y ?? 0, 1); -}; +export function TransactionBreakdownGraph({ fetchStatus, timeseries }: Props) { + const history = useHistory(); + const chartRef = React.createRef(); + const { event, setEvent } = useChartsSync2(); + const { urlParams } = useUrlParams(); + const { start, end } = urlParams; -const formatTooltipValue = (coordinate: Coordinate) => { - return isValidCoordinateValue(coordinate.y) - ? asPercent(coordinate.y, 1) - : NOT_AVAILABLE_LABEL; -}; + useEffect(() => { + if (event.chartId !== 'timeSpentBySpan' && chartRef.current) { + chartRef.current.dispatchExternalPointerEvent(event); + } + }, [chartRef, event]); -function TransactionBreakdownGraph({ timeseries, noHits }: Props) { - const { urlParams } = useUrlParams(); - const { rangeFrom, rangeTo } = urlParams; - const trackApmEvent = useUiTracker({ app: 'apm' }); - const handleHover = useMemo( - () => - throttle(() => trackApmEvent({ metric: 'hover_breakdown_chart' }), 60000), - [trackApmEvent] - ); + const min = moment.utc(start).valueOf(); + const max = moment.utc(end).valueOf(); - const emptySeries = - rangeFrom && rangeTo - ? getEmptySeries( - new Date(rangeFrom).getTime(), - new Date(rangeTo).getTime() - ) - : []; + const xFormatter = niceTimeFormatter([min, max]); return ( - + + + onBrushEnd({ x, history })} + showLegend + showLegendExtra + legendPosition={Position.Bottom} + xDomain={{ min, max }} + flatLegend + onPointerUpdate={(currEvent: any) => { + setEvent(currEvent); + }} + externalPointerEvents={{ + tooltip: { visible: true, placement: Placement.Bottom }, + }} + /> + + asPercent(y ?? 0, 1)} + /> + + + + {timeseries?.length ? ( + timeseries.map((serie) => { + return ( + + ); + }) + ) : ( + // When timeseries is empty, loads an AreaSeries chart to show the default empty message. + + )} + + ); } - -export { TransactionBreakdownGraph }; diff --git a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx index 55826497ca385..9b0c041aaf7b5 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx @@ -5,16 +5,13 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { isEmpty } from 'lodash'; import React from 'react'; -import { FETCH_STATUS } from '../../../hooks/useFetcher'; import { useTransactionBreakdown } from '../../../hooks/useTransactionBreakdown'; import { TransactionBreakdownGraph } from './TransactionBreakdownGraph'; function TransactionBreakdown() { const { data, status } = useTransactionBreakdown(); const { timeseries } = data; - const noHits = isEmpty(timeseries) && status === FETCH_STATUS.SUCCESS; return ( @@ -29,7 +26,10 @@ function TransactionBreakdown() { - +
diff --git a/x-pack/plugins/apm/public/components/shared/charts/Histogram/SingleRect.js b/x-pack/plugins/apm/public/components/shared/charts/Histogram/SingleRect.js deleted file mode 100644 index ca85ee961f5d8..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/Histogram/SingleRect.js +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import PropTypes from 'prop-types'; - -function SingleRect({ innerHeight, marginTop, style, x, width }) { - return ( - - ); -} - -SingleRect.requiresSVG = true; -SingleRect.propTypes = { - x: PropTypes.number.isRequired, -}; - -export default SingleRect; diff --git a/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/Histogram.test.js b/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/Histogram.test.js deleted file mode 100644 index 03fd039a3401e..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/Histogram.test.js +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; - -import d3 from 'd3'; -import { HistogramInner } from '../index'; -import response from './response.json'; -import { - disableConsoleWarning, - toJson, - mountWithTheme, -} from '../../../../../utils/testHelpers'; -import { getFormattedBuckets } from '../../../../app/TransactionDetails/Distribution/index'; -import { - asInteger, - getDurationFormatter, -} from '../../../../../../common/utils/formatters'; - -describe('Histogram', () => { - let mockConsole; - let wrapper; - - const onClick = jest.fn(); - - beforeAll(() => { - mockConsole = disableConsoleWarning('Warning: componentWillReceiveProps'); - }); - - afterAll(() => { - mockConsole.mockRestore(); - }); - - beforeEach(() => { - const buckets = getFormattedBuckets(response.buckets, response.bucketSize); - const xMax = d3.max(buckets, (d) => d.x); - const timeFormatter = getDurationFormatter(xMax); - - wrapper = mountWithTheme( - timeFormatter(time).formatted} - formatYShort={(t) => `${asInteger(t)} occ.`} - formatYLong={(t) => `${asInteger(t)} occurrences`} - tooltipHeader={(bucket) => { - const xFormatted = timeFormatter(bucket.x); - const x0Formatted = timeFormatter(bucket.x0); - return `${x0Formatted.value} - ${xFormatted.value} ${xFormatted.unit}`; - }} - width={800} - /> - ); - }); - - describe('Initially', () => { - it('should have default markup', () => { - expect(toJson(wrapper)).toMatchSnapshot(); - }); - - it('should not show tooltip', () => { - expect(wrapper.find('Tooltip').length).toBe(0); - }); - }); - - describe('when hovering over an empty bucket', () => { - beforeEach(() => { - wrapper.find('.rv-voronoi__cell').at(2).simulate('mouseOver'); - }); - - it('should not display tooltip', () => { - expect(wrapper.find('Tooltip').length).toBe(0); - }); - }); - - describe('when hovering over a non-empty bucket', () => { - beforeEach(() => { - wrapper.find('.rv-voronoi__cell').at(7).simulate('mouseOver'); - }); - - it('should display tooltip', () => { - const tooltips = wrapper.find('Tooltip'); - - expect(tooltips.length).toBe(1); - expect(tooltips.prop('header')).toBe('811 - 927 ms'); - expect(tooltips.prop('tooltipPoints')).toEqual([ - { value: '49 occurrences' }, - ]); - expect(tooltips.prop('x')).toEqual(869010); - expect(tooltips.prop('y')).toEqual(27.5); - }); - - it('should have correct markup for tooltip', () => { - const tooltips = wrapper.find('Tooltip'); - expect(toJson(tooltips)).toMatchSnapshot(); - }); - }); - - describe('when clicking on a non-empty bucket', () => { - beforeEach(() => { - wrapper.find('.rv-voronoi__cell').at(7).simulate('click'); - }); - - it('should call onClick with bucket', () => { - expect(onClick).toHaveBeenCalledWith({ - style: { cursor: 'pointer' }, - xCenter: 869010, - x0: 811076, - x: 926944, - y: 49, - }); - }); - }); -}); diff --git a/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/__snapshots__/Histogram.test.js.snap b/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/__snapshots__/Histogram.test.js.snap deleted file mode 100644 index a31b9735628ab..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/__snapshots__/Histogram.test.js.snap +++ /dev/null @@ -1,1504 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Histogram Initially should have default markup 1`] = ` -.c0 { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - position: absolute; - top: 0; - left: 0; -} - -
- -
-
- - - - - - - - - - - - - 0 ms - - - - - - 500 ms - - - - - - 1,000 ms - - - - - - 1,500 ms - - - - - - 2,000 ms - - - - - - 2,500 ms - - - - - - 3,000 ms - - - - - - - - - - 0 occ. - - - - - - 28 occ. - - - - - - 55 occ. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
-`; - -exports[`Histogram when hovering over a non-empty bucket should have correct markup for tooltip 1`] = ` -.c0 { - margin: 0 16px; - -webkit-transform: translateY(-50%); - -ms-transform: translateY(-50%); - transform: translateY(-50%); - border: 1px solid #d3dae6; - background: #ffffff; - border-radius: 4px; - font-size: 14px; - color: #000000; -} - -.c1 { - background: #f5f7fa; - border-bottom: 1px solid #d3dae6; - border-radius: 4px 4px 0 0; - padding: 8px; - color: #98a2b3; -} - -.c2 { - margin: 8px; - margin-right: 16px; - font-size: 12px; -} - -.c4 { - color: #98a2b3; - margin: 8px; - font-size: 12px; -} - -.c3 { - color: #69707d; - font-size: 14px; -} - -
- -
- -
- 811 - 927 ms -
-
- -
- -
- 49 occurrences -
-
-
-
- -
- -
-
-
-`; diff --git a/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/response.json b/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/response.json deleted file mode 100644 index 302e105dfa997..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/response.json +++ /dev/null @@ -1,106 +0,0 @@ -{ - "buckets": [ - { "key": 0, "count": 0 }, - { "key": 115868, "count": 0 }, - { "key": 231736, "count": 0 }, - { "key": 347604, "count": 0 }, - { "key": 463472, "count": 0 }, - { - "key": 579340, - "count": 8, - "samples": [ - { - "transactionId": "99437ee4-08d4-41f5-9b2b-93cc32ec3dfb" - } - ] - }, - { - "key": 695208, - "count": 23, - "samples": [ - { - "transactionId": "d327611b-e999-4942-a94f-c60208940180" - } - ] - }, - { - "key": 811076, - "count": 49, - "samples": [ - { - "transactionId": "99c50a5b-44b4-4289-a3d1-a2815d128192" - } - ] - }, - { - "key": 926944, - "count": 51, - "transactionId": "9706a1ec-23f5-4ce8-97e8-69ce35fb0a9a" - }, - { - "key": 1042812, - "count": 46, - "transactionId": "f8d360c3-dd5e-47b6-b082-9e0bf821d3b2" - }, - { - "key": 1158680, - "count": 13, - "samples": [ - { - "transactionId": "8486d3e2-7f15-48df-aa37-6ee9955adbd2" - } - ] - }, - { - "key": 1274548, - "count": 7, - "transactionId": "54b4b5a7-f065-4cab-9016-534e58f4fc0a" - }, - { - "key": 1390416, - "count": 4, - "transactionId": "8cfac2a3-38e7-4d3a-9792-d008b4bcb867" - }, - { - "key": 1506284, - "count": 3, - "transactionId": "ce3f3bd3-a37c-419e-bb9c-5db956ded149" - }, - { "key": 1622152, "count": 0 }, - { - "key": 1738020, - "count": 4, - "transactionId": "2300174b-85d8-40ba-a6cb-eeba2a49debf" - }, - { "key": 1853888, "count": 0 }, - { "key": 1969756, "count": 0 }, - { - "key": 2085624, - "count": 1, - "transactionId": "774955a4-2ba3-4461-81a6-65759db4805d" - }, - { "key": 2201492, "count": 0 }, - { "key": 2317360, "count": 0 }, - { "key": 2433228, "count": 0 }, - { "key": 2549096, "count": 0 }, - { "key": 2664964, "count": 0 }, - { - "key": 2780832, - "count": 1, - "transactionId": "035d1b9d-af71-46cf-8910-57bd4faf412d" - }, - { - "key": 2896700, - "count": 1, - "transactionId": "4a845b32-9de4-4796-8ef4-d7bbdedc9099" - }, - { "key": 3012568, "count": 0 }, - { - "key": 3128436, - "count": 1, - "transactionId": "68620ffb-7a1b-4f8e-b9bb-009fa5b092be" - } - ], - "bucketSize": 115868, - "defaultBucketIndex": 12 -} diff --git a/x-pack/plugins/apm/public/components/shared/charts/Histogram/index.js b/x-pack/plugins/apm/public/components/shared/charts/Histogram/index.js deleted file mode 100644 index 3b2109d68c613..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/Histogram/index.js +++ /dev/null @@ -1,319 +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, { PureComponent } from 'react'; -import d3 from 'd3'; -import { isEmpty } from 'lodash'; -import PropTypes from 'prop-types'; -import { scaleLinear } from 'd3-scale'; -import styled from 'styled-components'; -import SingleRect from './SingleRect'; -import { - XYPlot, - XAxis, - YAxis, - HorizontalGridLines, - VerticalRectSeries, - Voronoi, - makeWidthFlexible, - VerticalGridLines, -} from 'react-vis'; -import { unit } from '../../../../style/variables'; -import Tooltip from '../Tooltip'; -import theme from '@elastic/eui/dist/eui_theme_light.json'; -import { tint } from 'polished'; -import { getTimeTicksTZ, getDomainTZ } from '../helper/timezone'; -import Legends from '../CustomPlot/Legends'; -import StatusText from '../CustomPlot/StatusText'; -import { i18n } from '@kbn/i18n'; -import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; - -const XY_HEIGHT = unit * 10; -const XY_MARGIN = { - top: unit, - left: unit * 5, - right: unit, - bottom: unit * 2, -}; - -const X_TICK_TOTAL = 8; - -// position absolutely to make sure that window resizing/zooming works -const ChartsWrapper = styled.div` - user-select: none; - position: absolute; - top: 0; - left: 0; -`; - -export class HistogramInner extends PureComponent { - constructor(props) { - super(props); - this.state = { - hoveredBucket: {}, - }; - } - - onClick = (bucket) => { - if (this.props.onClick) { - this.props.onClick(bucket); - } - }; - - onHover = (bucket) => { - this.setState({ hoveredBucket: bucket }); - }; - - onBlur = () => { - this.setState({ hoveredBucket: {} }); - }; - - getChartData(items, selectedItem) { - const yMax = d3.max(items, (d) => d.y); - const MINIMUM_BUCKET_SIZE = yMax * 0.02; - - return items.map((item) => { - const padding = (item.x - item.x0) / 20; - return { - ...item, - color: - item === selectedItem - ? theme.euiColorVis1 - : tint(0.5, theme.euiColorVis1), - x0: item.x0 + padding, - x: item.x - padding, - y: item.y > 0 ? Math.max(item.y, MINIMUM_BUCKET_SIZE) : 0, - }; - }); - } - - render() { - const { - backgroundHover, - bucketIndex, - buckets, - bucketSize, - formatX, - formatYShort, - formatYLong, - tooltipFooter, - tooltipHeader, - verticalLineHover, - width: XY_WIDTH, - height, - legends, - } = this.props; - const { hoveredBucket } = this.state; - if (isEmpty(buckets) || XY_WIDTH === 0) { - return null; - } - - const isTimeSeries = - this.props.xType === 'time' || this.props.xType === 'time-utc'; - - const xMin = d3.min(buckets, (d) => d.x0); - const xMax = d3.max(buckets, (d) => d.x); - const yMin = 0; - const yMax = d3.max(buckets, (d) => d.y); - const selectedBucket = buckets[bucketIndex]; - const chartData = this.getChartData(buckets, selectedBucket); - - const x = scaleLinear() - .domain([xMin, xMax]) - .range([XY_MARGIN.left, XY_WIDTH - XY_MARGIN.right]); - - const y = scaleLinear().domain([yMin, yMax]).range([XY_HEIGHT, 0]).nice(); - - const [xMinZone, xMaxZone] = getDomainTZ(xMin, xMax); - const xTickValues = isTimeSeries - ? getTimeTicksTZ({ - domain: [xMinZone, xMaxZone], - totalTicks: X_TICK_TOTAL, - width: XY_WIDTH, - }) - : undefined; - - const xDomain = x.domain(); - const yDomain = y.domain(); - const yTickValues = [0, yDomain[1] / 2, yDomain[1]]; - const shouldShowTooltip = - hoveredBucket.x > 0 && (hoveredBucket.y > 0 || isTimeSeries); - - const showVerticalLineHover = verticalLineHover(hoveredBucket); - const showBackgroundHover = backgroundHover(hoveredBucket); - - const hasValidCoordinates = buckets.some((bucket) => - isValidCoordinateValue(bucket.y) - ); - const noHits = this.props.noHits || !hasValidCoordinates; - - const xyPlotProps = { - dontCheckIfEmpty: true, - xType: this.props.xType, - width: XY_WIDTH, - height: XY_HEIGHT, - margin: XY_MARGIN, - xDomain: xDomain, - yDomain: yDomain, - }; - - const xAxisProps = { - style: { strokeWidth: '1px' }, - marginRight: 10, - tickSize: 0, - tickTotal: X_TICK_TOTAL, - tickFormat: formatX, - tickValues: xTickValues, - }; - - const emptyStateChart = ( - - - - - ); - - return ( -
- - {noHits ? ( - <>{emptyStateChart} - ) : ( - <> - - - - - - {showBackgroundHover && ( - - )} - - {shouldShowTooltip && ( - - )} - - {selectedBucket && ( - - )} - - - - {showVerticalLineHover && hoveredBucket?.x && ( - - )} - - { - return { - ...bucket, - xCenter: (bucket.x0 + bucket.x) / 2, - }; - })} - onClick={this.onClick} - onHover={this.onHover} - onBlur={this.onBlur} - x={(d) => x(d.xCenter)} - y={() => 1} - /> - - - {legends && ( - {}} - truncateLegends={false} - noHits={noHits} - /> - )} - - )} - -
- ); - } -} - -HistogramInner.propTypes = { - backgroundHover: PropTypes.func, - bucketIndex: PropTypes.number, - buckets: PropTypes.array.isRequired, - bucketSize: PropTypes.number.isRequired, - formatX: PropTypes.func, - formatYLong: PropTypes.func, - formatYShort: PropTypes.func, - onClick: PropTypes.func, - tooltipFooter: PropTypes.func, - tooltipHeader: PropTypes.func, - verticalLineHover: PropTypes.func, - width: PropTypes.number.isRequired, - height: PropTypes.number, - xType: PropTypes.string, - legends: PropTypes.array, - noHits: PropTypes.bool, -}; - -HistogramInner.defaultProps = { - backgroundHover: () => null, - formatYLong: (value) => value, - formatYShort: (value) => value, - tooltipFooter: () => null, - tooltipHeader: () => null, - verticalLineHover: () => null, - xType: 'linear', - noHits: false, - height: XY_HEIGHT, -}; - -export default makeWidthFlexible(HistogramInner); diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx deleted file mode 100644 index 2e4b51af00d6b..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx +++ /dev/null @@ -1,70 +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, { useCallback } from 'react'; -import { Coordinate, TimeSeries } from '../../../../../../typings/timeseries'; -import { useLegacyChartsSync as useChartsSync } from '../../../../../hooks/use_charts_sync'; -// @ts-expect-error -import CustomPlot from '../../CustomPlot'; - -interface Props { - series: TimeSeries[]; - truncateLegends?: boolean; - tickFormatY: (y: number) => React.ReactNode; - formatTooltipValue: (c: Coordinate) => React.ReactNode; - yMax?: string | number; - height?: number; - stacked?: boolean; - onHover?: () => void; - visibleLegendCount?: number; - onToggleLegend?: (disabledSeriesState: boolean[]) => void; -} - -function TransactionLineChart(props: Props) { - const { - series, - tickFormatY, - formatTooltipValue, - yMax = 'max', - height, - truncateLegends, - stacked = false, - onHover, - visibleLegendCount, - onToggleLegend, - } = props; - - const syncedChartsProps = useChartsSync(); - - // combine callback for syncedChartsProps.onHover and props.onHover - const combinedOnHover = useCallback( - (hoverX: number) => { - if (onHover) { - onHover(); - } - return syncedChartsProps.onHover(hoverX); - }, - [syncedChartsProps, onHover] - ); - - return ( - - ); -} - -export { TransactionLineChart }; diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx index b3c0c3b6de857..2a5948d0ebf0b 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx @@ -20,104 +20,107 @@ import { TRANSACTION_REQUEST, TRANSACTION_ROUTE_CHANGE, } from '../../../../../common/transaction_types'; +import { asDecimal, tpmUnit } from '../../../../../common/utils/formatters'; import { Coordinate } from '../../../../../typings/timeseries'; +import { ChartsSyncContextProvider } from '../../../../context/charts_sync_context'; import { LicenseContext } from '../../../../context/LicenseContext'; import { IUrlParams } from '../../../../context/UrlParamsContext/types'; +import { FETCH_STATUS } from '../../../../hooks/useFetcher'; import { ITransactionChartData } from '../../../../selectors/chartSelectors'; -import { asDecimal, tpmUnit } from '../../../../../common/utils/formatters'; import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; -import { ErroneousTransactionsRateChart } from '../erroneous_transactions_rate_chart/legacy'; import { TransactionBreakdown } from '../../TransactionBreakdown'; -import { - getResponseTimeTickFormatter, - getResponseTimeTooltipFormatter, -} from './helper'; +import { LineChart } from '../line_chart'; +import { TransactionErrorRateChart } from '../transaction_error_rate_chart/'; +import { getResponseTimeTickFormatter } from './helper'; import { MLHeader } from './ml_header'; -import { TransactionLineChart } from './TransactionLineChart'; import { useFormatter } from './use_formatter'; interface TransactionChartProps { charts: ITransactionChartData; urlParams: IUrlParams; + fetchStatus: FETCH_STATUS; } export function TransactionCharts({ charts, urlParams, + fetchStatus, }: TransactionChartProps) { const getTPMFormatter = (t: number) => { - const unit = tpmUnit(urlParams.transactionType); - return `${asDecimal(t)} ${unit}`; + return `${asDecimal(t)} ${tpmUnit(urlParams.transactionType)}`; }; - const getTPMTooltipFormatter = (p: Coordinate) => { - return isValidCoordinateValue(p.y) - ? getTPMFormatter(p.y) - : NOT_AVAILABLE_LABEL; + const getTPMTooltipFormatter = (y: Coordinate['y']) => { + return isValidCoordinateValue(y) ? getTPMFormatter(y) : NOT_AVAILABLE_LABEL; }; const { transactionType } = urlParams; const { responseTimeSeries, tpmSeries } = charts; - const { formatter, setDisabledSeriesState } = useFormatter( - responseTimeSeries - ); + const { formatter, toggleSerie } = useFormatter(responseTimeSeries); return ( <> - - - - - - - {responseTimeLabel(transactionType)} - - - - {(license) => ( - - )} - - - - - + + + + + + + + {responseTimeLabel(transactionType)} + + + + {(license) => ( + + )} + + + { + if (serie) { + toggleSerie(serie); + } + }} + /> + + - - - - {tpmLabel(transactionType)} - - - - - + + + + {tpmLabel(transactionType)} + + + + + - + - - - - - - - - + + + + + + + + + ); } diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.test.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.test.tsx index fc873cbda7bf2..958a5db6b66c9 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.test.tsx @@ -3,38 +3,17 @@ * 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 { SeriesIdentifier } from '@elastic/charts'; +import { renderHook } from '@testing-library/react-hooks'; +import { act } from 'react-test-renderer'; +import { toMicroseconds } from '../../../../../common/utils/formatters'; import { TimeSeries } from '../../../../../typings/timeseries'; import { useFormatter } from './use_formatter'; -import { render, fireEvent, act } from '@testing-library/react'; -import { toMicroseconds } from '../../../../../common/utils/formatters'; - -function MockComponent({ - timeSeries, - disabledSeries, - value, -}: { - timeSeries: TimeSeries[]; - disabledSeries: boolean[]; - value: number; -}) { - const { formatter, setDisabledSeriesState } = useFormatter(timeSeries); - - const onDisableSeries = () => { - setDisabledSeriesState(disabledSeries); - }; - - return ( -
- - {formatter(value).formatted} -
- ); -} describe('useFormatter', () => { const timeSeries = ([ { + title: 'avg', data: [ { x: 1, y: toMicroseconds(11, 'minutes') }, { x: 2, y: toMicroseconds(1, 'minutes') }, @@ -42,6 +21,7 @@ describe('useFormatter', () => { ], }, { + title: '95th percentile', data: [ { x: 1, y: toMicroseconds(120, 'seconds') }, { x: 2, y: toMicroseconds(1, 'minutes') }, @@ -49,6 +29,7 @@ describe('useFormatter', () => { ], }, { + title: '99th percentile', data: [ { x: 1, y: toMicroseconds(60, 'seconds') }, { x: 2, y: toMicroseconds(5, 'minutes') }, @@ -56,54 +37,47 @@ describe('useFormatter', () => { ], }, ] as unknown) as TimeSeries[]; + it('returns new formatter when disabled series state changes', () => { - const { getByText } = render( - - ); - expect(getByText('2.0 min')).toBeInTheDocument(); + const { result } = renderHook(() => useFormatter(timeSeries)); + expect( + result.current.formatter(toMicroseconds(120, 'seconds')).formatted + ).toEqual('2.0 min'); + act(() => { - fireEvent.click(getByText('disable series')); + result.current.toggleSerie({ + specId: 'avg', + } as SeriesIdentifier); }); - expect(getByText('120 s')).toBeInTheDocument(); + + expect( + result.current.formatter(toMicroseconds(120, 'seconds')).formatted + ).toEqual('120 s'); }); + it('falls back to the first formatter when disabled series is empty', () => { - const { getByText } = render( - - ); - expect(getByText('2.0 min')).toBeInTheDocument(); + const { result } = renderHook(() => useFormatter(timeSeries)); + expect( + result.current.formatter(toMicroseconds(120, 'seconds')).formatted + ).toEqual('2.0 min'); + act(() => { - fireEvent.click(getByText('disable series')); + result.current.toggleSerie({ + specId: 'avg', + } as SeriesIdentifier); }); - expect(getByText('2.0 min')).toBeInTheDocument(); - // const { formatter, setDisabledSeriesState } = useFormatter(timeSeries); - // expect(formatter(toMicroseconds(120, 'seconds'))).toEqual('2.0 min'); - // setDisabledSeriesState([true, true, false]); - // expect(formatter(toMicroseconds(120, 'seconds'))).toEqual('2.0 min'); - }); - it('falls back to the first formatter when disabled series is all true', () => { - const { getByText } = render( - - ); - expect(getByText('2.0 min')).toBeInTheDocument(); + + expect( + result.current.formatter(toMicroseconds(120, 'seconds')).formatted + ).toEqual('120 s'); + act(() => { - fireEvent.click(getByText('disable series')); + result.current.toggleSerie({ + specId: 'avg', + } as SeriesIdentifier); }); - expect(getByText('2.0 min')).toBeInTheDocument(); - // const { formatter, setDisabledSeriesState } = useFormatter(timeSeries); - // expect(formatter(toMicroseconds(120, 'seconds'))).toEqual('2.0 min'); - // setDisabledSeriesState([true, true, false]); - // expect(formatter(toMicroseconds(120, 'seconds'))).toEqual('2.0 min'); + expect( + result.current.formatter(toMicroseconds(120, 'seconds')).formatted + ).toEqual('2.0 min'); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.ts b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.ts index d4694bc3caf1d..1475ec2934e95 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.ts +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.ts @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useState, Dispatch, SetStateAction } from 'react'; -import { isEmpty } from 'lodash'; +import { SeriesIdentifier } from '@elastic/charts'; +import { omit } from 'lodash'; +import { useState } from 'react'; import { getDurationFormatter, TimeFormatter, @@ -14,17 +15,36 @@ import { TimeSeries } from '../../../../../typings/timeseries'; import { getMaxY } from './helper'; export const useFormatter = ( - series: TimeSeries[] + series?: TimeSeries[] ): { formatter: TimeFormatter; - setDisabledSeriesState: Dispatch>; + toggleSerie: (disabledSerie: SeriesIdentifier) => void; } => { - const [disabledSeriesState, setDisabledSeriesState] = useState([]); - const visibleSeries = series.filter( - (serie, index) => disabledSeriesState[index] !== true + const [disabledSeries, setDisabledSeries] = useState< + Record + >({}); + + const visibleSeries = series?.filter( + (serie) => disabledSeries[serie.title] === undefined ); - const maxY = getMaxY(isEmpty(visibleSeries) ? series : visibleSeries); + + const maxY = getMaxY(visibleSeries || series || []); const formatter = getDurationFormatter(maxY); - return { formatter, setDisabledSeriesState }; + const toggleSerie = ({ specId }: SeriesIdentifier) => { + if (disabledSeries[specId] !== undefined) { + setDisabledSeries((prevState) => { + return omit(prevState, specId); + }); + } else { + setDisabledSeries((prevState) => { + return { ...prevState, [specId]: 0 }; + }); + } + }; + + return { + formatter, + toggleSerie, + }; }; diff --git a/x-pack/plugins/apm/public/components/shared/charts/annotations/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/annotations/index.tsx new file mode 100644 index 0000000000000..683c66b2a96fe --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/charts/annotations/index.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 { + AnnotationDomainTypes, + LineAnnotation, + Position, +} from '@elastic/charts'; +import { EuiIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { asAbsoluteDateTime } from '../../../../../common/utils/formatters'; +import { useTheme } from '../../../../hooks/useTheme'; +import { useAnnotations } from '../../../../hooks/use_annotations'; + +export function Annotations() { + const { annotations } = useAnnotations(); + const theme = useTheme(); + + if (!annotations.length) { + return null; + } + + const color = theme.eui.euiColorSecondary; + + return ( + ({ + dataValue: annotation['@timestamp'], + header: asAbsoluteDateTime(annotation['@timestamp']), + details: `${i18n.translate('xpack.apm.chart.annotation.version', { + defaultMessage: 'Version', + })} ${annotation.text}`, + }))} + style={{ line: { strokeWidth: 1, stroke: color, opacity: 1 } }} + marker={} + markerPosition={Position.Top} + /> + ); +} diff --git a/x-pack/plugins/apm/public/components/shared/charts/chart_container.test.tsx b/x-pack/plugins/apm/public/components/shared/charts/chart_container.test.tsx index 409cb69575ca9..c0e8f869ce647 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/chart_container.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/chart_container.test.tsx @@ -5,30 +5,97 @@ */ import { render } from '@testing-library/react'; import React from 'react'; +import { FETCH_STATUS } from '../../../hooks/useFetcher'; import { ChartContainer } from './chart_container'; describe('ChartContainer', () => { - describe('when isLoading is true', () => { - it('shows loading the indicator', () => { - const component = render( - + describe('loading indicator', () => { + it('shows loading when status equals to Loading or Pending and has no data', () => { + [FETCH_STATUS.PENDING, FETCH_STATUS.LOADING].map((status) => { + const { queryAllByTestId } = render( + +
My amazing component
+
+ ); + + expect(queryAllByTestId('loading')[0]).toBeInTheDocument(); + }); + }); + it('does not show loading when status equals to Loading or Pending and has data', () => { + [FETCH_STATUS.PENDING, FETCH_STATUS.LOADING].map((status) => { + const { queryAllByText } = render( + +
My amazing component
+
+ ); + expect(queryAllByText('My amazing component')[0]).toBeInTheDocument(); + }); + }); + }); + + describe('failure indicator', () => { + it('shows failure message when status equals to Failure and has data', () => { + const { getByText } = render( +
My amazing component
); - - expect(component.getByTestId('loading')).toBeInTheDocument(); + expect( + getByText( + 'An error happened when trying to fetch data. Please try again' + ) + ).toBeInTheDocument(); + }); + it('shows failure message when status equals to Failure and has no data', () => { + const { getByText } = render( + +
My amazing component
+
+ ); + expect( + getByText( + 'An error happened when trying to fetch data. Please try again' + ) + ).toBeInTheDocument(); }); }); - describe('when isLoading is false', () => { - it('does not show the loading indicator', () => { - const component = render( - + describe('render component', () => { + it('shows children component when status Success and has data', () => { + const { getByText } = render( +
My amazing component
); - - expect(component.queryByTestId('loading')).not.toBeInTheDocument(); + expect(getByText('My amazing component')).toBeInTheDocument(); + }); + it('shows children component when status Success and has no data', () => { + const { getByText } = render( + +
My amazing component
+
+ ); + expect(getByText('My amazing component')).toBeInTheDocument(); }); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/charts/chart_container.tsx b/x-pack/plugins/apm/public/components/shared/charts/chart_container.tsx index a6f579308597f..b4486f1e9b94a 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/chart_container.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/chart_container.tsx @@ -3,27 +3,56 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { EuiLoadingChart } from '@elastic/eui'; + +import { EuiLoadingChart, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import React from 'react'; +import { FETCH_STATUS } from '../../../hooks/useFetcher'; interface Props { - isLoading: boolean; + hasData: boolean; + status: FETCH_STATUS; height: number; children: React.ReactNode; } -export function ChartContainer({ isLoading, children, height }: Props) { +export function ChartContainer({ children, height, status, hasData }: Props) { + if ( + !hasData && + (status === FETCH_STATUS.LOADING || status === FETCH_STATUS.PENDING) + ) { + return ; + } + + if (status === FETCH_STATUS.FAILURE) { + return ; + } + + return
{children}
; +} + +function LoadingChartPlaceholder({ height }: { height: number }) { return (
- {isLoading && } - {children} +
); } + +function FailedChartPlaceholder({ height }: { height: number }) { + return ( + + {i18n.translate('xpack.apm.chart.error', { + defaultMessage: + 'An error happened when trying to fetch data. Please try again', + })} + + ); +} diff --git a/x-pack/plugins/apm/public/components/shared/charts/erroneous_transactions_rate_chart/legacy.tsx b/x-pack/plugins/apm/public/components/shared/charts/erroneous_transactions_rate_chart/legacy.tsx deleted file mode 100644 index 29102f606414f..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/erroneous_transactions_rate_chart/legacy.tsx +++ /dev/null @@ -1,112 +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 { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; -import theme from '@elastic/eui/dist/eui_theme_light.json'; -import { i18n } from '@kbn/i18n'; -import { max } from 'lodash'; -import React, { useCallback } from 'react'; -import { useParams } from 'react-router-dom'; -import { asPercent } from '../../../../../common/utils/formatters'; -import { useLegacyChartsSync as useChartsSync } from '../../../../hooks/use_charts_sync'; -import { useFetcher } from '../../../../hooks/useFetcher'; -import { useUrlParams } from '../../../../hooks/useUrlParams'; -import { callApmApi } from '../../../../services/rest/createCallApmApi'; -// @ts-expect-error -import CustomPlot from '../CustomPlot'; - -const tickFormatY = (y?: number | null) => { - return asPercent(y || 0, 1); -}; - -/** - * "Legacy" version of this chart using react-vis charts. See index.tsx for the - * Elastic Charts version. - * - * This will be removed with #70290. - */ -export function ErroneousTransactionsRateChart() { - const { serviceName } = useParams<{ serviceName?: string }>(); - const { urlParams, uiFilters } = useUrlParams(); - const syncedChartsProps = useChartsSync(); - - const { start, end, transactionType, transactionName } = urlParams; - - const { data } = useFetcher(() => { - if (serviceName && start && end) { - return callApmApi({ - pathname: - '/api/apm/services/{serviceName}/transaction_groups/error_rate', - params: { - path: { - serviceName, - }, - query: { - start, - end, - transactionType, - transactionName, - uiFilters: JSON.stringify(uiFilters), - }, - }, - }); - } - }, [serviceName, start, end, uiFilters, transactionType, transactionName]); - - const combinedOnHover = useCallback( - (hoverX: number) => { - return syncedChartsProps.onHover(hoverX); - }, - [syncedChartsProps] - ); - - const errorRates = data?.transactionErrorRate || []; - const maxRate = max(errorRates.map((errorRate) => errorRate.y)); - - return ( - - - - {i18n.translate('xpack.apm.errorRateChart.title', { - defaultMessage: 'Transaction error rate', - })} - - - - - Number.isFinite(y) ? tickFormatY(y) : 'N/A' - } - /> - - ); -} diff --git a/x-pack/plugins/apm/public/components/shared/charts/line_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/line_chart/index.tsx index 3f2a08ecb7641..507acc49d89db 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/line_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/line_chart/index.tsx @@ -20,15 +20,17 @@ import moment from 'moment'; import React, { useEffect } from 'react'; import { useHistory } from 'react-router-dom'; import { TimeSeries } from '../../../../../typings/timeseries'; +import { FETCH_STATUS } from '../../../../hooks/useFetcher'; import { useUrlParams } from '../../../../hooks/useUrlParams'; import { useChartsSync } from '../../../../hooks/use_charts_sync'; import { unit } from '../../../../style/variables'; +import { Annotations } from '../annotations'; import { ChartContainer } from '../chart_container'; import { onBrushEnd } from '../helper/helper'; interface Props { id: string; - isLoading: boolean; + fetchStatus: FETCH_STATUS; onToggleLegend?: LegendItemListener; timeseries: TimeSeries[]; /** @@ -38,18 +40,20 @@ interface Props { /** * Formatter for legend and tooltip values */ - yTickFormat: (y: number) => string; + yTickFormat?: (y: number) => string; + showAnnotations?: boolean; } const XY_HEIGHT = unit * 16; export function LineChart({ id, - isLoading, + fetchStatus, onToggleLegend, timeseries, yLabelFormat, yTickFormat, + showAnnotations = true, }: Props) { const history = useHistory(); const chartRef = React.createRef(); @@ -84,7 +88,7 @@ export function LineChart({ ); return ( - + onBrushEnd({ x, history })} @@ -115,11 +119,13 @@ export function LineChart({ id="y-axis" ticks={3} position={Position.Left} - tickFormat={yTickFormat} + tickFormat={yTickFormat ? yTickFormat : yLabelFormat} labelFormat={yLabelFormat} showGridLines /> + {showAnnotations && } + {timeseries.map((serie) => { return ( (); const { urlParams, uiFilters } = useUrlParams(); @@ -56,25 +61,32 @@ export function ErroneousTransactionsRateChart() { const errorRates = data?.transactionErrorRate || []; return ( - + + +

+ {i18n.translate('xpack.apm.errorRate', { + defaultMessage: 'Error rate', + })} +

+
+ +
); } diff --git a/x-pack/plugins/apm/public/hooks/useTransactionBreakdown.ts b/x-pack/plugins/apm/public/hooks/useTransactionBreakdown.ts index 08d2300c3254a..0705383ecb0ca 100644 --- a/x-pack/plugins/apm/public/hooks/useTransactionBreakdown.ts +++ b/x-pack/plugins/apm/public/hooks/useTransactionBreakdown.ts @@ -15,7 +15,7 @@ export function useTransactionBreakdown() { uiFilters, } = useUrlParams(); - const { data = { timeseries: [] }, error, status } = useFetcher( + const { data = { timeseries: undefined }, error, status } = useFetcher( (callApmApi) => { if (serviceName && start && end && transactionType) { return callApmApi({ diff --git a/x-pack/plugins/apm/public/hooks/useTransactionDistribution.ts b/x-pack/plugins/apm/public/hooks/useTransactionDistribution.ts index a5096a314388c..8c76225d03486 100644 --- a/x-pack/plugins/apm/public/hooks/useTransactionDistribution.ts +++ b/x-pack/plugins/apm/public/hooks/useTransactionDistribution.ts @@ -10,7 +10,7 @@ import { IUrlParams } from '../context/UrlParamsContext/types'; import { useFetcher } from './useFetcher'; import { useUiFilters } from '../context/UrlParamsContext'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { TransactionDistributionAPIResponse } from '../../server/lib/transactions/distribution'; +import type { TransactionDistributionAPIResponse } from '../../server/lib/transactions/distribution'; import { toQuery, fromQuery } from '../components/shared/Links/url_helpers'; import { maybe } from '../../common/utils/maybe'; diff --git a/x-pack/plugins/apm/public/hooks/useTransactionList.ts b/x-pack/plugins/apm/public/hooks/useTransactionList.ts index 9c3a18b9c0d0d..b2c2cc30f78ec 100644 --- a/x-pack/plugins/apm/public/hooks/useTransactionList.ts +++ b/x-pack/plugins/apm/public/hooks/useTransactionList.ts @@ -14,8 +14,8 @@ type TransactionsAPIResponse = APIReturnType< '/api/apm/services/{serviceName}/transaction_groups' >; -const DEFAULT_RESPONSE: TransactionsAPIResponse = { - items: [], +const DEFAULT_RESPONSE: Partial = { + items: undefined, isAggregationAccurate: true, bucketSize: 0, }; diff --git a/x-pack/plugins/apm/public/hooks/use_annotations.ts b/x-pack/plugins/apm/public/hooks/use_annotations.ts new file mode 100644 index 0000000000000..2b1c2bec52b3d --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_annotations.ts @@ -0,0 +1,38 @@ +/* + * 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 { useParams } from 'react-router-dom'; +import { callApmApi } from '../services/rest/createCallApmApi'; +import { useFetcher } from './useFetcher'; +import { useUrlParams } from './useUrlParams'; + +const INITIAL_STATE = { annotations: [] }; + +export function useAnnotations() { + const { serviceName } = useParams<{ serviceName?: string }>(); + const { urlParams, uiFilters } = useUrlParams(); + const { start, end } = urlParams; + const { environment } = uiFilters; + + const { data = INITIAL_STATE } = useFetcher(() => { + if (start && end && serviceName) { + return callApmApi({ + pathname: '/api/apm/services/{serviceName}/annotation/search', + params: { + path: { + serviceName, + }, + query: { + start, + end, + environment, + }, + }, + }); + } + }, [start, end, environment, serviceName]); + + return data; +} diff --git a/x-pack/plugins/apm/public/selectors/chartSelectors.ts b/x-pack/plugins/apm/public/selectors/chartSelectors.ts index 8c6093859f969..450f02f70c6a4 100644 --- a/x-pack/plugins/apm/public/selectors/chartSelectors.ts +++ b/x-pack/plugins/apm/public/selectors/chartSelectors.ts @@ -31,40 +31,37 @@ export interface ITpmBucket { } export interface ITransactionChartData { - tpmSeries: ITpmBucket[]; - responseTimeSeries: TimeSeries[]; + tpmSeries?: ITpmBucket[]; + responseTimeSeries?: TimeSeries[]; mlJobId: string | undefined; } -const INITIAL_DATA = { - apmTimeseries: { - responseTimes: { - avg: [], - p95: [], - p99: [], - }, - tpmBuckets: [], - overallAvgDuration: null, - }, +const INITIAL_DATA: Partial = { + apmTimeseries: undefined, anomalyTimeseries: undefined, }; export function getTransactionCharts( { transactionType }: IUrlParams, - { apmTimeseries, anomalyTimeseries }: TimeSeriesAPIResponse = INITIAL_DATA + charts = INITIAL_DATA ): ITransactionChartData { - const tpmSeries = getTpmSeries(apmTimeseries, transactionType); - - const responseTimeSeries = getResponseTimeSeries({ - apmTimeseries, - anomalyTimeseries, - }); + const { apmTimeseries, anomalyTimeseries } = charts; - return { - tpmSeries, - responseTimeSeries, + const transactionCharts: ITransactionChartData = { + tpmSeries: undefined, + responseTimeSeries: undefined, mlJobId: anomalyTimeseries?.jobId, }; + + if (apmTimeseries) { + transactionCharts.tpmSeries = getTpmSeries(apmTimeseries, transactionType); + + transactionCharts.responseTimeSeries = getResponseTimeSeries({ + apmTimeseries, + anomalyTimeseries, + }); + } + return transactionCharts; } export function getResponseTimeSeries({ diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index 090110b0454c0..29a0d1fdf4249 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -43,6 +43,7 @@ export const config = { ), telemetryCollectionEnabled: schema.boolean({ defaultValue: true }), metricsInterval: schema.number({ defaultValue: 30 }), + maxServiceEnvironments: schema.number({ defaultValue: 100 }), }), }; @@ -74,6 +75,7 @@ export function mergeConfigs( 'xpack.apm.serviceMapMaxTracesPerRequest': apmConfig.serviceMapMaxTracesPerRequest, 'xpack.apm.ui.enabled': apmConfig.ui.enabled, + 'xpack.apm.maxServiceEnvironments': apmConfig.maxServiceEnvironments, 'xpack.apm.ui.maxTraceItems': apmConfig.ui.maxTraceItems, 'xpack.apm.ui.transactionGroupBucketSize': apmConfig.ui.transactionGroupBucketSize, diff --git a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts index 7b63f2c354916..ecda5b0e8504b 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts @@ -66,6 +66,7 @@ export function registerErrorCountAlertType({ config, savedObjectsClient: services.savedObjectsClient, }); + const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments']; const searchParams = { index: indices['apm_oss.errorIndices'], @@ -100,6 +101,7 @@ export function registerErrorCountAlertType({ environments: { terms: { field: SERVICE_ENVIRONMENT, + size: maxServiceEnvironments, }, }, }, diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts index 1d8b664751ba2..d9e69c8f3b7d7 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts @@ -75,6 +75,7 @@ export function registerTransactionDurationAlertType({ config, savedObjectsClient: services.savedObjectsClient, }); + const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments']; const searchParams = { index: indices['apm_oss.transactionIndices'], @@ -112,6 +113,7 @@ export function registerTransactionDurationAlertType({ environments: { terms: { field: SERVICE_ENVIRONMENT, + size: maxServiceEnvironments, }, }, }, diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts index 969f4ceaca93a..06b296db5a485 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts @@ -71,6 +71,7 @@ export function registerTransactionErrorRateAlertType({ config, savedObjectsClient: services.savedObjectsClient, }); + const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments']; const searchParams = { index: indices['apm_oss.transactionIndices'], @@ -120,6 +121,7 @@ export function registerTransactionErrorRateAlertType({ environments: { terms: { field: SERVICE_ENVIRONMENT, + size: maxServiceEnvironments, }, }, }, diff --git a/x-pack/plugins/apm/server/lib/environments/get_all_environments.ts b/x-pack/plugins/apm/server/lib/environments/get_all_environments.ts index 95ff357937d47..39b4f7a7fe81b 100644 --- a/x-pack/plugins/apm/server/lib/environments/get_all_environments.ts +++ b/x-pack/plugins/apm/server/lib/environments/get_all_environments.ts @@ -24,7 +24,8 @@ export async function getAllEnvironments({ searchAggregatedTransactions: boolean; includeMissing?: boolean; }) { - const { apmEventClient } = setup; + const { apmEventClient, config } = setup; + const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments']; // omit filter for service.name if "All" option is selected const serviceNameFilter = serviceName @@ -55,7 +56,7 @@ export async function getAllEnvironments({ environments: { terms: { field: SERVICE_ENVIRONMENT, - size: 100, + size: maxServiceEnvironments, ...(!serviceName ? { min_doc_count: 0 } : {}), missing: includeMissing ? ENVIRONMENT_NOT_DEFINED.value : undefined, }, diff --git a/x-pack/plugins/apm/server/lib/errors/distribution/get_buckets.ts b/x-pack/plugins/apm/server/lib/errors/distribution/get_buckets.ts index a42710947a792..b12dd73a20986 100644 --- a/x-pack/plugins/apm/server/lib/errors/distribution/get_buckets.ts +++ b/x-pack/plugins/apm/server/lib/errors/distribution/get_buckets.ts @@ -73,6 +73,6 @@ export async function getBuckets({ return { noHits: resp.hits.total.value === 0, - buckets, + buckets: resp.hits.total.value > 0 ? buckets : [], }; } diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts index 2bfd3c94ed34c..9020cb1b9953a 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts @@ -7,14 +7,16 @@ import { ValuesType } from 'utility-types'; import { APMBaseDoc } from '../../../../../typings/es_schemas/raw/apm_base_doc'; import { APMError } from '../../../../../typings/es_schemas/ui/apm_error'; -import { KibanaRequest } from '../../../../../../../../src/core/server'; +import { + KibanaRequest, + LegacyScopedClusterClient, +} from '../../../../../../../../src/core/server'; import { ProcessorEvent } from '../../../../../common/processor_event'; import { ESSearchRequest, ESSearchResponse, } from '../../../../../typings/elasticsearch'; import { ApmIndicesConfig } from '../../../settings/apm_indices/get_apm_indices'; -import { APMRequestHandlerContext } from '../../../../routes/typings'; import { addFilterToExcludeLegacyData } from './add_filter_to_exclude_legacy_data'; import { callClientWithDebug } from '../call_client_with_debug'; import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; @@ -51,20 +53,23 @@ type TypedSearchResponse< export type APMEventClient = ReturnType; export function createApmEventClient({ - context, + esClient, + debug, request, indices, options: { includeFrozen } = { includeFrozen: false }, }: { - context: APMRequestHandlerContext; + esClient: Pick< + LegacyScopedClusterClient, + 'callAsInternalUser' | 'callAsCurrentUser' + >; + debug: boolean; request: KibanaRequest; indices: ApmIndicesConfig; options: { includeFrozen: boolean; }; }) { - const client = context.core.elasticsearch.legacy.client; - return { search( params: TParams, @@ -77,14 +82,14 @@ export function createApmEventClient({ : withProcessorEventFilter; return callClientWithDebug({ - apiCaller: client.callAsCurrentUser, + apiCaller: esClient.callAsCurrentUser, operationName: 'search', params: { ...withPossibleLegacyDataFilter, ignore_throttled: !includeFrozen, }, request, - debug: context.params.query._debug, + debug, }); }, }; diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts index 5e75535c678b3..363c4128137e0 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts @@ -88,7 +88,8 @@ export async function setupRequest( const coreSetupRequest = { indices, apmEventClient: createApmEventClient({ - context, + esClient: context.core.elasticsearch.legacy.client, + debug: context.params.query._debug, request, indices, options: { includeFrozen }, diff --git a/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap index 3a38f80c87b35..a6818f96c728e 100644 --- a/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap @@ -366,6 +366,7 @@ Array [ "environments": Object { "terms": Object { "field": "service.environment", + "size": 100, }, }, }, diff --git a/x-pack/plugins/apm/server/lib/services/get_services/get_services_items_stats.ts b/x-pack/plugins/apm/server/lib/services/get_services/get_services_items_stats.ts index 7d190c5b66450..fac80cf22c310 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/get_services_items_stats.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/get_services_items_stats.ts @@ -337,7 +337,8 @@ export const getEnvironments = async ({ setup, projection, }: AggregationParams) => { - const { apmEventClient } = setup; + const { apmEventClient, config } = setup; + const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments']; const response = await apmEventClient.search( mergeProjection(projection, { body: { @@ -352,6 +353,7 @@ export const getEnvironments = async ({ environments: { terms: { field: SERVICE_ENVIRONMENT, + size: maxServiceEnvironments, }, }, }, diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/settings/agent_configuration/__snapshots__/queries.test.ts.snap index 8db97a4929eb0..18ef3f44331d9 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/__snapshots__/queries.test.ts.snap @@ -127,7 +127,7 @@ Object { "terms": Object { "field": "service.environment", "missing": "ALL_OPTION_VALUE", - "size": 50, + "size": 100, }, }, }, diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_existing_environments_for_service.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_existing_environments_for_service.ts index 8327ac59a95b2..5e19f8f211cf7 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_existing_environments_for_service.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_existing_environments_for_service.ts @@ -18,7 +18,8 @@ export async function getExistingEnvironmentsForService({ serviceName: string | undefined; setup: Setup; }) { - const { internalClient, indices } = setup; + const { internalClient, indices, config } = setup; + const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments']; const bool = serviceName ? { filter: [{ term: { [SERVICE_NAME]: serviceName } }] } @@ -34,7 +35,7 @@ export async function getExistingEnvironmentsForService({ terms: { field: SERVICE_ENVIRONMENT, missing: ALL_OPTION_VALUE, - size: 50, + size: maxServiceEnvironments, }, }, }, diff --git a/x-pack/plugins/apm/server/lib/ui_filters/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/ui_filters/__snapshots__/queries.test.ts.snap index d94b766aee6a8..3baaefe203ce7 100644 --- a/x-pack/plugins/apm/server/lib/ui_filters/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/ui_filters/__snapshots__/queries.test.ts.snap @@ -15,6 +15,7 @@ Object { "terms": Object { "field": "service.environment", "missing": "ENVIRONMENT_NOT_DEFINED", + "size": 100, }, }, }, @@ -58,6 +59,7 @@ Object { "terms": Object { "field": "service.environment", "missing": "ENVIRONMENT_NOT_DEFINED", + "size": 100, }, }, }, diff --git a/x-pack/plugins/apm/server/lib/ui_filters/get_environments.ts b/x-pack/plugins/apm/server/lib/ui_filters/get_environments.ts index e72cc7e2483ad..b9f25e20f9f73 100644 --- a/x-pack/plugins/apm/server/lib/ui_filters/get_environments.ts +++ b/x-pack/plugins/apm/server/lib/ui_filters/get_environments.ts @@ -24,7 +24,7 @@ export async function getEnvironments({ serviceName?: string; searchAggregatedTransactions: boolean; }) { - const { start, end, apmEventClient } = setup; + const { start, end, apmEventClient, config } = setup; const filter: ESFilter[] = [{ range: rangeFilter(start, end) }]; @@ -34,6 +34,8 @@ export async function getEnvironments({ }); } + const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments']; + const params = { apm: { events: [ @@ -56,6 +58,7 @@ export async function getEnvironments({ terms: { field: SERVICE_ENVIRONMENT, missing: ENVIRONMENT_NOT_DEFINED.value, + size: maxServiceEnvironments, }, }, }, diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index d3341b6c1b163..44269b1775953 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -10,14 +10,17 @@ import { map, take } from 'rxjs/operators'; import { CoreSetup, CoreStart, + KibanaRequest, Logger, Plugin, PluginInitializerContext, + RequestHandlerContext, } from 'src/core/server'; import { APMConfig, APMXPackConfig, mergeConfigs } from '.'; import { APMOSSPluginSetup } from '../../../../src/plugins/apm_oss/server'; import { HomeServerPluginSetup } from '../../../../src/plugins/home/server'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; +import { UI_SETTINGS } from '../../../../src/plugins/data/common'; import { ActionsPlugin } from '../../actions/server'; import { AlertingPlugin } from '../../alerts/server'; import { CloudSetup } from '../../cloud/server'; @@ -30,6 +33,7 @@ import { TaskManagerSetupContract } from '../../task_manager/server'; import { APM_FEATURE, registerFeaturesUsage } from './feature'; import { registerApmAlerts } from './lib/alerts/register_apm_alerts'; import { createApmTelemetry } from './lib/apm_telemetry'; +import { createApmEventClient } from './lib/helpers/create_es_client/create_apm_event_client'; import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_objects_client'; import { createApmAgentConfigurationIndex } from './lib/settings/agent_configuration/create_agent_config_index'; import { getApmIndices } from './lib/settings/apm_indices/get_apm_indices'; @@ -42,6 +46,11 @@ import { uiSettings } from './ui_settings'; export interface APMPluginSetup { config$: Observable; getApmIndices: () => ReturnType; + createApmEventClient: (params: { + debug?: boolean; + request: KibanaRequest; + context: RequestHandlerContext; + }) => Promise>; } export class APMPlugin implements Plugin { @@ -141,13 +150,41 @@ export class APMPlugin implements Plugin { }, }); + const boundGetApmIndices = async () => + getApmIndices({ + savedObjectsClient: await getInternalSavedObjectsClient(core), + config: await mergedConfig$.pipe(take(1)).toPromise(), + }); + return { config$: mergedConfig$, - getApmIndices: async () => - getApmIndices({ - savedObjectsClient: await getInternalSavedObjectsClient(core), - config: await mergedConfig$.pipe(take(1)).toPromise(), - }), + getApmIndices: boundGetApmIndices, + createApmEventClient: async ({ + request, + context, + debug, + }: { + debug?: boolean; + request: KibanaRequest; + context: RequestHandlerContext; + }) => { + const [indices, includeFrozen] = await Promise.all([ + boundGetApmIndices(), + context.core.uiSettings.client.get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN), + ]); + + const esClient = context.core.elasticsearch.legacy.client; + + return createApmEventClient({ + debug: debug ?? false, + esClient, + request, + indices, + options: { + includeFrozen, + }, + }); + }, }; } diff --git a/x-pack/plugins/apm/server/utils/test_helpers.tsx b/x-pack/plugins/apm/server/utils/test_helpers.tsx index 18b990b35b5a5..21b59dc516d06 100644 --- a/x-pack/plugins/apm/server/utils/test_helpers.tsx +++ b/x-pack/plugins/apm/server/utils/test_helpers.tsx @@ -76,6 +76,9 @@ export async function inspectSearchParams( case 'xpack.apm.metricsInterval': return 30; + + case 'xpack.apm.maxServiceEnvironments': + return 100; } }, } diff --git a/x-pack/plugins/canvas/shareable_runtime/api/index.ts b/x-pack/plugins/canvas/shareable_runtime/api/index.ts index 0780ab46cd873..dc7445eb7bc5a 100644 --- a/x-pack/plugins/canvas/shareable_runtime/api/index.ts +++ b/x-pack/plugins/canvas/shareable_runtime/api/index.ts @@ -7,5 +7,7 @@ import 'core-js/stable'; import 'regenerator-runtime/runtime'; import 'whatwg-fetch'; +import 'jquery'; +import '@kbn/ui-shared-deps/flot_charts'; export * from './shareable'; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx index 0b9f47e188d15..646978dd68153 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx @@ -221,6 +221,11 @@ export const setup = async () => { setFreeze, setIndexPriority: setIndexPriority('cold'), }, + delete: { + enable: enable('delete'), + setMinAgeValue: setMinAgeValue('delete'), + setMinAgeUnits: setMinAgeUnits('delete'), + }, }, }; }; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts index 11fadf51f27f8..4ee67d1ed8a19 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts @@ -367,7 +367,6 @@ describe('', () => { expect(testBed.find('snapshotPolicyCombobox').prop('data-currentvalue')).toEqual([ { label: DELETE_PHASE_POLICY.policy.phases.delete?.actions.wait_for_snapshot?.policy, - value: DELETE_PHASE_POLICY.policy.phases.delete?.actions.wait_for_snapshot?.policy, }, ]); }); @@ -412,7 +411,7 @@ describe('', () => { test('wait for snapshot field should delete action if field is empty', async () => { const { actions } = testBed; - actions.setWaitForSnapshotPolicy(''); + await actions.setWaitForSnapshotPolicy(''); await actions.savePolicy(); const expected = { diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx index 4a3fedfb264ac..43910583ceec9 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx @@ -20,27 +20,27 @@ import { notificationServiceMock, fatalErrorsServiceMock, } from '../../../../../src/core/public/mocks'; + import { usageCollectionPluginMock } from '../../../../../src/plugins/usage_collection/public/mocks'; + import { CloudSetup } from '../../../cloud/public'; import { EditPolicy } from '../../public/application/sections/edit_policy/edit_policy'; +import { + EditPolicyContextProvider, + EditPolicyContextValue, +} from '../../public/application/sections/edit_policy/edit_policy_context'; + import { KibanaContextProvider } from '../../public/shared_imports'; + import { init as initHttp } from '../../public/application/services/http'; import { init as initUiMetric } from '../../public/application/services/ui_metric'; import { init as initNotification } from '../../public/application/services/notification'; import { PolicyFromES } from '../../common/types'; -import { - positiveNumberRequiredMessage, - policyNameRequiredMessage, - policyNameStartsWithUnderscoreErrorMessage, - policyNameContainsCommaErrorMessage, - policyNameContainsSpaceErrorMessage, - policyNameMustBeDifferentErrorMessage, - policyNameAlreadyUsedErrorMessage, -} from '../../public/application/services/policies/policy_validation'; import { i18nTexts } from '../../public/application/sections/edit_policy/i18n_texts'; import { editPolicyHelpers } from './helpers'; +import { defaultPolicy } from '../../public/application/constants'; // @ts-ignore initHttp(axios.create({ adapter: axiosXhrAdapter })); @@ -122,14 +122,11 @@ const noRollover = async (rendered: ReactWrapper) => { const getNodeAttributeSelect = (rendered: ReactWrapper, phase: string) => { return findTestSubject(rendered, `${phase}-selectedNodeAttrs`); }; -const setPolicyName = (rendered: ReactWrapper, policyName: string) => { +const setPolicyName = async (rendered: ReactWrapper, policyName: string) => { const policyNameField = findTestSubject(rendered, 'policyNameField'); - policyNameField.simulate('change', { target: { value: policyName } }); - rendered.update(); -}; -const setPhaseAfterLegacy = (rendered: ReactWrapper, phase: string, after: string | number) => { - const afterInput = rendered.find(`input#${phase}-selectedMinimumAge`); - afterInput.simulate('change', { target: { value: after } }); + await act(async () => { + policyNameField.simulate('change', { target: { value: policyName } }); + }); rendered.update(); }; const setPhaseAfter = async (rendered: ReactWrapper, phase: string, after: string | number) => { @@ -157,6 +154,32 @@ const save = async (rendered: ReactWrapper) => { }); rendered.update(); }; + +const MyComponent = ({ + isCloudEnabled, + isNewPolicy, + policy: _policy, + existingPolicies, + getUrlForApp, + policyName, +}: EditPolicyContextValue & { isCloudEnabled: boolean }) => { + return ( + + + + + + ); +}; + describe('edit policy', () => { beforeAll(() => { jest.useFakeTimers(); @@ -179,14 +202,14 @@ describe('edit policy', () => { beforeEach(() => { component = ( - - - + ); ({ http } = editPolicyHelpers.setup()); @@ -198,62 +221,78 @@ describe('edit policy', () => { test('should show error when trying to save empty form', async () => { const rendered = mountWithIntl(component); await save(rendered); - expectedErrorMessages(rendered, [policyNameRequiredMessage]); + expectedErrorMessages(rendered, [i18nTexts.editPolicy.errors.policyNameRequiredMessage]); }); test('should show error when trying to save policy name with space', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'my policy'); - await save(rendered); - expectedErrorMessages(rendered, [policyNameContainsSpaceErrorMessage]); + await setPolicyName(rendered, 'my policy'); + waitForFormLibValidation(rendered); + expectedErrorMessages(rendered, [i18nTexts.editPolicy.errors.policyNameContainsInvalidChars]); }); test('should show error when trying to save policy name that is already used', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'testy0'); - rendered.update(); - await save(rendered); - expectedErrorMessages(rendered, [policyNameAlreadyUsedErrorMessage]); + await setPolicyName(rendered, 'testy0'); + waitForFormLibValidation(rendered); + expectedErrorMessages(rendered, [ + i18nTexts.editPolicy.errors.policyNameAlreadyUsedErrorMessage, + ]); }); test('should show error when trying to save as new policy but using the same name', async () => { component = ( - ); const rendered = mountWithIntl(component); findTestSubject(rendered, 'saveAsNewSwitch').simulate('click'); rendered.update(); - setPolicyName(rendered, 'testy0'); - await save(rendered); - expectedErrorMessages(rendered, [policyNameMustBeDifferentErrorMessage]); + await setPolicyName(rendered, 'testy0'); + waitForFormLibValidation(rendered); + expectedErrorMessages(rendered, [ + i18nTexts.editPolicy.errors.policyNameAlreadyUsedErrorMessage, + ]); }); test('should show error when trying to save policy name with comma', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'my,policy'); - await save(rendered); - expectedErrorMessages(rendered, [policyNameContainsCommaErrorMessage]); + await setPolicyName(rendered, 'my,policy'); + waitForFormLibValidation(rendered); + expectedErrorMessages(rendered, [i18nTexts.editPolicy.errors.policyNameContainsInvalidChars]); }); test('should show error when trying to save policy name starting with underscore', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, '_mypolicy'); - await save(rendered); - expectedErrorMessages(rendered, [policyNameStartsWithUnderscoreErrorMessage]); + await setPolicyName(rendered, '_mypolicy'); + waitForFormLibValidation(rendered); + expectedErrorMessages(rendered, [ + i18nTexts.editPolicy.errors.policyNameStartsWithUnderscoreErrorMessage, + ]); }); test('should show correct json in policy flyout', async () => { - const rendered = mountWithIntl(component); + const rendered = mountWithIntl( + + ); await act(async () => { findTestSubject(rendered, 'requestButton').simulate('click'); }); rendered.update(); + const json = rendered.find(`code`).text(); - const expected = `PUT _ilm/policy/\n${JSON.stringify( + const expected = `PUT _ilm/policy/my-policy\n${JSON.stringify( { policy: { phases: { @@ -282,7 +321,7 @@ describe('edit policy', () => { test('should show errors when trying to save with no max size and no max age', async () => { const rendered = mountWithIntl(component); expect(findTestSubject(rendered, 'rolloverSettingsRequired').exists()).toBeFalsy(); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); const maxSizeInput = findTestSubject(rendered, 'hot-selectedMaxSizeStored'); await act(async () => { maxSizeInput.simulate('change', { target: { value: '' } }); @@ -298,7 +337,7 @@ describe('edit policy', () => { }); test('should show number above 0 required error when trying to save with -1 for max size', async () => { const rendered = mountWithIntl(component); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); const maxSizeInput = findTestSubject(rendered, 'hot-selectedMaxSizeStored'); await act(async () => { maxSizeInput.simulate('change', { target: { value: '-1' } }); @@ -309,7 +348,7 @@ describe('edit policy', () => { }); test('should show number above 0 required error when trying to save with 0 for max size', async () => { const rendered = mountWithIntl(component); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); const maxSizeInput = findTestSubject(rendered, 'hot-selectedMaxSizeStored'); await act(async () => { maxSizeInput.simulate('change', { target: { value: '-1' } }); @@ -319,7 +358,7 @@ describe('edit policy', () => { }); test('should show number above 0 required error when trying to save with -1 for max age', async () => { const rendered = mountWithIntl(component); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); const maxAgeInput = findTestSubject(rendered, 'hot-selectedMaxAge'); await act(async () => { maxAgeInput.simulate('change', { target: { value: '-1' } }); @@ -329,7 +368,7 @@ describe('edit policy', () => { }); test('should show number above 0 required error when trying to save with 0 for max age', async () => { const rendered = mountWithIntl(component); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); const maxAgeInput = findTestSubject(rendered, 'hot-selectedMaxAge'); await act(async () => { maxAgeInput.simulate('change', { target: { value: '0' } }); @@ -337,21 +376,21 @@ describe('edit policy', () => { waitForFormLibValidation(rendered); expectedErrorMessages(rendered, [i18nTexts.editPolicy.errors.numberGreatThan0Required]); }); - test('should show forcemerge input when rollover enabled', () => { + test('should show forcemerge input when rollover enabled', async () => { const rendered = mountWithIntl(component); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); expect(findTestSubject(rendered, 'hot-forceMergeSwitch').exists()).toBeTruthy(); }); test('should hide forcemerge input when rollover is disabled', async () => { const rendered = mountWithIntl(component); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await noRollover(rendered); waitForFormLibValidation(rendered); expect(findTestSubject(rendered, 'hot-forceMergeSwitch').exists()).toBeFalsy(); }); test('should show positive number required above zero error when trying to save hot phase with 0 for force merge', async () => { const rendered = mountWithIntl(component); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); act(() => { findTestSubject(rendered, 'hot-forceMergeSwitch').simulate('click'); }); @@ -365,7 +404,7 @@ describe('edit policy', () => { }); test('should show positive number above 0 required error when trying to save hot phase with -1 for force merge', async () => { const rendered = mountWithIntl(component); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); findTestSubject(rendered, 'hot-forceMergeSwitch').simulate('click'); rendered.update(); const forcemergeInput = findTestSubject(rendered, 'hot-selectedForceMergeSegments'); @@ -379,7 +418,7 @@ describe('edit policy', () => { test('should show positive number required error when trying to save with -1 for index priority', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await setPhaseIndexPriority(rendered, 'hot', '-1'); waitForFormLibValidation(rendered); expectedErrorMessages(rendered, [i18nTexts.editPolicy.errors.nonNegativeNumberRequired]); @@ -397,7 +436,7 @@ describe('edit policy', () => { test('should show number required error when trying to save empty warm phase', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); await setPhaseAfter(rendered, 'warm', ''); waitForFormLibValidation(rendered); @@ -406,7 +445,7 @@ describe('edit policy', () => { test('should allow 0 for phase timing', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); await setPhaseAfter(rendered, 'warm', '0'); waitForFormLibValidation(rendered); @@ -415,7 +454,7 @@ describe('edit policy', () => { test('should show positive number required error when trying to save warm phase with -1 for after', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); await setPhaseAfter(rendered, 'warm', '-1'); waitForFormLibValidation(rendered); @@ -424,7 +463,7 @@ describe('edit policy', () => { test('should show positive number required error when trying to save warm phase with -1 for index priority', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); await setPhaseAfter(rendered, 'warm', '1'); await setPhaseAfter(rendered, 'warm', '-1'); @@ -434,7 +473,7 @@ describe('edit policy', () => { test('should show positive number required above zero error when trying to save warm phase with 0 for shrink', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); act(() => { findTestSubject(rendered, 'shrinkSwitch').simulate('click'); @@ -451,7 +490,7 @@ describe('edit policy', () => { test('should show positive number above 0 required error when trying to save warm phase with -1 for shrink', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); await setPhaseAfter(rendered, 'warm', '1'); act(() => { @@ -468,7 +507,7 @@ describe('edit policy', () => { test('should show positive number required above zero error when trying to save warm phase with 0 for force merge', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); await setPhaseAfter(rendered, 'warm', '1'); act(() => { @@ -485,7 +524,7 @@ describe('edit policy', () => { test('should show positive number above 0 required error when trying to save warm phase with -1 for force merge', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); await setPhaseAfter(rendered, 'warm', '1'); await act(async () => { @@ -503,7 +542,7 @@ describe('edit policy', () => { server.respondImmediately = false; const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeTruthy(); expect(rendered.find('.euiCallOut--warning').exists()).toBeFalsy(); @@ -517,7 +556,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); await openNodeAttributesSection(rendered, 'warm'); @@ -527,7 +566,7 @@ describe('edit policy', () => { test('should show node attributes input when attributes exist', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); await openNodeAttributesSection(rendered, 'warm'); @@ -539,7 +578,7 @@ describe('edit policy', () => { test('should show view node attributes link when attribute selected and show flyout when clicked', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); await openNodeAttributesSection(rendered, 'warm'); @@ -568,7 +607,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); expect(findTestSubject(rendered, 'defaultAllocationWarning').exists()).toBeTruthy(); @@ -581,7 +620,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); expect(findTestSubject(rendered, 'defaultAllocationNotice').exists()).toBeTruthy(); @@ -594,7 +633,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); expect(findTestSubject(rendered, 'defaultAllocationNotice').exists()).toBeFalsy(); @@ -611,7 +650,7 @@ describe('edit policy', () => { test('should allow 0 for phase timing', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); await setPhaseAfter(rendered, 'cold', '0'); waitForFormLibValidation(rendered); @@ -621,7 +660,7 @@ describe('edit policy', () => { test('should show positive number required error when trying to save cold phase with -1 for after', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); await setPhaseAfter(rendered, 'cold', '-1'); waitForFormLibValidation(rendered); @@ -631,7 +670,7 @@ describe('edit policy', () => { server.respondImmediately = false; const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeTruthy(); expect(rendered.find('.euiCallOut--warning').exists()).toBeFalsy(); @@ -645,7 +684,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); await openNodeAttributesSection(rendered, 'cold'); @@ -655,7 +694,7 @@ describe('edit policy', () => { test('should show node attributes input when attributes exist', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); await openNodeAttributesSection(rendered, 'cold'); @@ -667,7 +706,7 @@ describe('edit policy', () => { test('should show view node attributes link when attribute selected and show flyout when clicked', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); await openNodeAttributesSection(rendered, 'cold'); @@ -689,7 +728,7 @@ describe('edit policy', () => { test('should show positive number required error when trying to save with -1 for index priority', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); await setPhaseAfter(rendered, 'cold', '1'); await setPhaseIndexPriority(rendered, 'cold', '-1'); @@ -704,7 +743,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); expect(findTestSubject(rendered, 'defaultAllocationWarning').exists()).toBeTruthy(); @@ -717,7 +756,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); expect(findTestSubject(rendered, 'defaultAllocationNotice').exists()).toBeTruthy(); @@ -730,7 +769,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); expect(findTestSubject(rendered, 'defaultAllocationNotice').exists()).toBeFalsy(); @@ -740,20 +779,20 @@ describe('edit policy', () => { test('should allow 0 for phase timing', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'delete'); - setPhaseAfterLegacy(rendered, 'delete', '0'); - await save(rendered); + await setPhaseAfter(rendered, 'delete', '0'); + waitForFormLibValidation(rendered); expectedErrorMessages(rendered, []); }); test('should show positive number required error when trying to save delete phase with -1 for after', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'delete'); - setPhaseAfterLegacy(rendered, 'delete', '-1'); - await save(rendered); - expectedErrorMessages(rendered, [positiveNumberRequiredMessage]); + await setPhaseAfter(rendered, 'delete', '-1'); + waitForFormLibValidation(rendered); + expectedErrorMessages(rendered, [i18nTexts.editPolicy.errors.nonNegativeNumberRequired]); }); }); describe('not on cloud', () => { @@ -768,7 +807,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); @@ -782,14 +821,13 @@ describe('edit policy', () => { describe('on cloud', () => { beforeEach(() => { component = ( - - - + ); ({ http } = editPolicyHelpers.setup()); ({ server, httpRequestsMockHelpers } = http); @@ -808,7 +846,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); @@ -829,7 +867,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); @@ -849,7 +887,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); expect(findTestSubject(rendered, 'cloudDataTierCallout').exists()).toBeTruthy(); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/lib/policies.ts b/x-pack/plugins/index_lifecycle_management/public/application/lib/policies.ts new file mode 100644 index 0000000000000..c4a91978a3765 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/lib/policies.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PolicyFromES } from '../../../common/types'; + +export const splitSizeAndUnits = (field: string): { size: string; units: string } => { + let size = ''; + let units = ''; + + const result = /(\d+)(\w+)/.exec(field); + if (result) { + size = result[1]; + units = result[2]; + } + + return { + size, + units, + }; +}; + +export const getPolicyByName = ( + policies: PolicyFromES[] | null | undefined, + policyName: string = '' +): PolicyFromES | undefined => { + if (policies && policies.length > 0) { + return policies.find((policy: PolicyFromES) => policy.name === policyName); + } +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts index a04608338718e..326f6ff87dc3b 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts @@ -7,11 +7,8 @@ export { ActiveBadge } from './active_badge'; export { ErrableFormRow } from './form_errors'; export { LearnMoreLink } from './learn_more_link'; -export { MinAgeInput } from './min_age_input_legacy'; export { OptionalLabel } from './optional_label'; -export { PhaseErrorMessage } from './phase_error_message'; export { PolicyJsonFlyout } from './policy_json_flyout'; -export { SnapshotPolicies } from './snapshot_policies'; export { DescribedFormField } from './described_form_field'; export * from './phases'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input_legacy.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input_legacy.tsx deleted file mode 100644 index 6fcf35b799289..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input_legacy.tsx +++ /dev/null @@ -1,263 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import React from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; - -import { EuiFieldNumber, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSelect } from '@elastic/eui'; - -import { LearnMoreLink } from './learn_more_link'; -import { ErrableFormRow } from './form_errors'; -import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; -import { PhaseWithMinAge, Phases } from '../../../../../common/types'; - -function getTimingLabelForPhase(phase: keyof Phases) { - // NOTE: Hot phase isn't necessary, because indices begin in the hot phase. - switch (phase) { - case 'warm': - return i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseWarm.minimumAgeLabel', { - defaultMessage: 'Timing for warm phase', - }); - - case 'cold': - return i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseCold.minimumAgeLabel', { - defaultMessage: 'Timing for cold phase', - }); - - case 'delete': - return i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseDelete.minimumAgeLabel', { - defaultMessage: 'Timing for delete phase', - }); - } -} - -function getUnitsAriaLabelForPhase(phase: keyof Phases) { - // NOTE: Hot phase isn't necessary, because indices begin in the hot phase. - switch (phase) { - case 'warm': - return i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.phaseWarm.minimumAgeUnitsAriaLabel', - { - defaultMessage: 'Units for timing of warm phase', - } - ); - - case 'cold': - return i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.phaseCold.minimumAgeUnitsAriaLabel', - { - defaultMessage: 'Units for timing of cold phase', - } - ); - - case 'delete': - return i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.phaseDelete.minimumAgeUnitsAriaLabel', - { - defaultMessage: 'Units for timing of delete phase', - } - ); - } -} - -interface Props { - rolloverEnabled: boolean; - errors?: PhaseValidationErrors; - phase: keyof Phases & string; - phaseData: T; - setPhaseData: (dataKey: keyof T & string, value: string) => void; - isShowingErrors: boolean; -} - -export const MinAgeInput = ({ - rolloverEnabled, - errors, - phaseData, - phase, - setPhaseData, - isShowingErrors, -}: React.PropsWithChildren>): React.ReactElement => { - let daysOptionLabel; - let hoursOptionLabel; - let minutesOptionLabel; - let secondsOptionLabel; - let millisecondsOptionLabel; - let microsecondsOptionLabel; - let nanosecondsOptionLabel; - - if (rolloverEnabled) { - daysOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverDaysOptionLabel', - { - defaultMessage: 'days from rollover', - } - ); - - hoursOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverHoursOptionLabel', - { - defaultMessage: 'hours from rollover', - } - ); - minutesOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverMinutesOptionLabel', - { - defaultMessage: 'minutes from rollover', - } - ); - - secondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverSecondsOptionLabel', - { - defaultMessage: 'seconds from rollover', - } - ); - millisecondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverMilliSecondsOptionLabel', - { - defaultMessage: 'milliseconds from rollover', - } - ); - - microsecondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverMicroSecondsOptionLabel', - { - defaultMessage: 'microseconds from rollover', - } - ); - - nanosecondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverNanoSecondsOptionLabel', - { - defaultMessage: 'nanoseconds from rollover', - } - ); - } else { - daysOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationDaysOptionLabel', - { - defaultMessage: 'days from index creation', - } - ); - - hoursOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationHoursOptionLabel', - { - defaultMessage: 'hours from index creation', - } - ); - - minutesOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationMinutesOptionLabel', - { - defaultMessage: 'minutes from index creation', - } - ); - - secondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationSecondsOptionLabel', - { - defaultMessage: 'seconds from index creation', - } - ); - - millisecondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationMilliSecondsOptionLabel', - { - defaultMessage: 'milliseconds from index creation', - } - ); - - microsecondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationMicroSecondsOptionLabel', - { - defaultMessage: 'microseconds from index creation', - } - ); - - nanosecondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationNanoSecondsOptionLabel', - { - defaultMessage: 'nanoseconds from index creation', - } - ); - } - - // check that these strings are valid properties - const selectedMinimumAgeProperty = propertyof('selectedMinimumAge'); - const selectedMinimumAgeUnitsProperty = propertyof('selectedMinimumAgeUnits'); - return ( - - - - } - /> - } - > - { - setPhaseData(selectedMinimumAgeProperty, e.target.value); - }} - min={0} - /> - - - - - setPhaseData(selectedMinimumAgeUnitsProperty, e.target.value)} - options={[ - { - value: 'd', - text: daysOptionLabel, - }, - { - value: 'h', - text: hoursOptionLabel, - }, - { - value: 'm', - text: minutesOptionLabel, - }, - { - value: 's', - text: secondsOptionLabel, - }, - { - value: 'ms', - text: millisecondsOptionLabel, - }, - { - value: 'micros', - text: microsecondsOptionLabel, - }, - { - value: 'nanos', - text: nanosecondsOptionLabel, - }, - ]} - /> - - - - ); -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_error_message.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_error_message.tsx deleted file mode 100644 index 750f68543f221..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_error_message.tsx +++ /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 React from 'react'; -import { EuiBadge } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; - -export const PhaseErrorMessage = ({ isShowingErrors }: { isShowingErrors: boolean }) => { - return isShowingErrors ? ( - - - - ) : null; -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx index 84e955a91ad7c..b87243bd1a9a1 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx @@ -13,19 +13,13 @@ import { EuiDescribedFormGroup, EuiTextColor } from '@elastic/eui'; import { Phases } from '../../../../../../../common/types'; -import { - useFormData, - useFormContext, - UseField, - ToggleField, - NumericField, -} from '../../../../../../shared_imports'; +import { useFormData, UseField, ToggleField, NumericField } from '../../../../../../shared_imports'; import { useEditPolicyContext } from '../../../edit_policy_context'; -import { LearnMoreLink, ActiveBadge, PhaseErrorMessage, DescribedFormField } from '../../'; +import { LearnMoreLink, ActiveBadge, DescribedFormField } from '../../'; -import { MinAgeInputField, DataTierAllocationField, SetPriorityInput } from '../shared'; +import { MinAgeInputField, DataTierAllocationField, SetPriorityInput } from '../shared_fields'; const i18nTexts = { dataTierAllocation: { @@ -43,15 +37,13 @@ const formFieldPaths = { }; export const ColdPhase: FunctionComponent = () => { - const { originalPolicy } = useEditPolicyContext(); - const form = useFormContext(); + const { policy } = useEditPolicyContext(); const [formData] = useFormData({ watch: [formFieldPaths.enabled], }); const enabled = get(formData, formFieldPaths.enabled); - const isShowingErrors = form.isValid === false; return (
@@ -66,8 +58,7 @@ export const ColdPhase: FunctionComponent = () => { defaultMessage="Cold phase" /> {' '} - {enabled && !isShowingErrors ? : null} - + {enabled && }
} titleSize="s" @@ -128,9 +119,7 @@ export const ColdPhase: FunctionComponent = () => { 'xpack.indexLifecycleMgmt.editPolicy.coldPhase.numberOfReplicas.switchLabel', { defaultMessage: 'Set replicas' } ), - initialValue: Boolean( - originalPolicy.phases.cold?.actions?.allocate?.number_of_replicas - ), + initialValue: Boolean(policy.phases.cold?.actions?.allocate?.number_of_replicas), }} fullWidth > diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx similarity index 50% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx index 78ae66327654c..37323b97edc92 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx @@ -7,53 +7,24 @@ import React, { FunctionComponent, Fragment } from 'react'; import { get } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiDescribedFormGroup, EuiSwitch, EuiTextColor, EuiFormRow } from '@elastic/eui'; +import { EuiDescribedFormGroup, EuiTextColor, EuiFormRow } from '@elastic/eui'; -import { DeletePhase as DeletePhaseInterface, Phases } from '../../../../../../common/types'; +import { useFormData, UseField, ToggleField } from '../../../../../../shared_imports'; -import { useFormData } from '../../../../../shared_imports'; +import { ActiveBadge, LearnMoreLink, OptionalLabel } from '../../index'; -import { PhaseValidationErrors } from '../../../../services/policies/policy_validation'; +import { MinAgeInputField, SnapshotPoliciesField } from '../shared_fields'; -import { - ActiveBadge, - LearnMoreLink, - OptionalLabel, - PhaseErrorMessage, - MinAgeInput, - SnapshotPolicies, -} from '../'; -import { useRolloverPath } from './shared'; - -const deleteProperty: keyof Phases = 'delete'; -const phaseProperty = (propertyName: keyof DeletePhaseInterface) => propertyName; - -interface Props { - setPhaseData: (key: keyof DeletePhaseInterface & string, value: string | boolean) => void; - phaseData: DeletePhaseInterface; - isShowingErrors: boolean; - errors?: PhaseValidationErrors; - getUrlForApp: ( - appId: string, - options?: { - path?: string; - absolute?: boolean; - } - ) => string; -} +const formFieldPaths = { + enabled: '_meta.delete.enabled', +}; -export const DeletePhase: FunctionComponent = ({ - setPhaseData, - phaseData, - errors, - isShowingErrors, - getUrlForApp, -}) => { +export const DeletePhase: FunctionComponent = () => { const [formData] = useFormData({ - watch: useRolloverPath, + watch: formFieldPaths.enabled, }); - const hotPhaseRolloverEnabled = get(formData, useRolloverPath); + const enabled = get(formData, formFieldPaths.enabled); return (
@@ -66,8 +37,7 @@ export const DeletePhase: FunctionComponent = ({ defaultMessage="Delete phase" /> {' '} - {phaseData.phaseEnabled && !isShowingErrors ? : null} - + {enabled && }
} titleSize="s" @@ -79,39 +49,23 @@ export const DeletePhase: FunctionComponent = ({ defaultMessage="You no longer need your index. You can define when it is safe to delete it." />

- - } - id={`${deleteProperty}-${phaseProperty('phaseEnabled')}`} - checked={phaseData.phaseEnabled} - onChange={(e) => { - setPhaseData(phaseProperty('phaseEnabled'), e.target.checked); + } fullWidth > - {phaseData.phaseEnabled ? ( - - errors={errors} - phaseData={phaseData} - phase={deleteProperty} - isShowingErrors={isShowingErrors} - setPhaseData={setPhaseData} - rolloverEnabled={hotPhaseRolloverEnabled} - /> - ) : ( -
- )} + {enabled && } - {phaseData.phaseEnabled ? ( + {enabled ? ( @@ -145,11 +99,7 @@ export const DeletePhase: FunctionComponent = ({ } > - setPhaseData(phaseProperty('waitForSnapshotPolicy'), value)} - getUrlForApp={getUrlForApp} - /> + ) : null} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/index.ts new file mode 100644 index 0000000000000..488e4e26cfce0 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { DeletePhase } from './delete_phase'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx index a184ddf5148b9..629c1388f61fb 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx @@ -19,7 +19,6 @@ import { import { Phases } from '../../../../../../../common/types'; import { - useFormContext, useFormData, UseField, SelectField, @@ -29,26 +28,24 @@ import { import { i18nTexts } from '../../../i18n_texts'; -import { ROLLOVER_EMPTY_VALIDATION } from '../../../form_validations'; +import { ROLLOVER_EMPTY_VALIDATION } from '../../../form'; import { ROLLOVER_FORM_PATHS } from '../../../constants'; -import { LearnMoreLink, ActiveBadge, PhaseErrorMessage } from '../../'; +import { LearnMoreLink, ActiveBadge } from '../../'; -import { Forcemerge, SetPriorityInput, useRolloverPath } from '../shared'; +import { Forcemerge, SetPriorityInput, useRolloverPath } from '../shared_fields'; import { maxSizeStoredUnits, maxAgeUnits } from './constants'; const hotProperty: keyof Phases = 'hot'; export const HotPhase: FunctionComponent = () => { - const form = useFormContext(); const [formData] = useFormData({ watch: useRolloverPath, }); const isRolloverEnabled = get(formData, useRolloverPath); - const isShowingErrors = form.isValid === false; const [showEmptyRolloverFieldsError, setShowEmptyRolloverFieldsError] = useState(false); return ( @@ -62,8 +59,7 @@ export const HotPhase: FunctionComponent = () => { defaultMessage="Hot phase" /> {' '} - {isShowingErrors ? null : } - +
} titleSize="s" diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/cloud_data_tier_callout.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/cloud_data_tier_callout.tsx similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/cloud_data_tier_callout.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/cloud_data_tier_callout.tsx diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/data_tier_allocation.scss b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/data_tier_allocation.scss similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/data_tier_allocation.scss rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/data_tier_allocation.scss diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/data_tier_allocation.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/data_tier_allocation.tsx similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/data_tier_allocation.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/data_tier_allocation.tsx diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/default_allocation_notice.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_notice.tsx similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/default_allocation_notice.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_notice.tsx diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/index.ts similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/index.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/index.ts diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/no_node_attributes_warning.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/no_node_attributes_warning.tsx similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/no_node_attributes_warning.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/no_node_attributes_warning.tsx diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/node_allocation.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/node_allocation.tsx similarity index 90% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/node_allocation.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/node_allocation.tsx index 407bb9ea92e85..c1676d7074dbc 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/node_allocation.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/node_allocation.tsx @@ -10,12 +10,8 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { EuiButtonEmpty, EuiText, EuiSpacer } from '@elastic/eui'; -import { PhaseWithAllocationAction } from '../../../../../../../../../common/types'; - import { UseField, SelectField, useFormData } from '../../../../../../../../shared_imports'; -import { propertyof } from '../../../../../../../services/policies/policy_validation'; - import { LearnMoreLink } from '../../../../learn_more_link'; import { NodeAttrsDetails } from './node_attrs_details'; @@ -61,9 +57,6 @@ export const NodeAllocation: FunctionComponent = ({ phase, nodes }) nodeOptions.sort((a, b) => a.value.localeCompare(b.value)); - // check that this string is a valid property - const nodeAttrsProperty = propertyof('selectedNodeAttrs'); - return ( <> @@ -100,7 +93,7 @@ export const NodeAllocation: FunctionComponent = ({ phase, nodes }) ) : undefined, euiFieldProps: { - 'data-test-subj': `${phase}-${nodeAttrsProperty}`, + 'data-test-subj': `${phase}-selectedNodeAttrs`, options: [{ text: i18nTexts.doNotModifyAllocationOption, value: '' }].concat( nodeOptions ), diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/node_attrs_details.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/node_attrs_details.tsx similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/node_attrs_details.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/node_attrs_details.tsx diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/node_data_provider.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/node_data_provider.tsx similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/node_data_provider.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/node_data_provider.tsx diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/types.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/types.ts similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/types.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/types.ts diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/data_tier_allocation_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/data_tier_allocation_field.tsx similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/data_tier_allocation_field.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/data_tier_allocation_field.tsx diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/index.ts similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/index.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/index.ts diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/forcemerge_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/forcemerge_field.tsx similarity index 94% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/forcemerge_field.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/forcemerge_field.tsx index b410bd0e6b3b0..b05d49be497cd 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/forcemerge_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/forcemerge_field.tsx @@ -21,11 +21,11 @@ interface Props { } export const Forcemerge: React.FunctionComponent = ({ phase }) => { - const { originalPolicy } = useEditPolicyContext(); + const { policy } = useEditPolicyContext(); const initialToggleValue = useMemo(() => { - return Boolean(originalPolicy.phases[phase]?.actions?.forcemerge); - }, [originalPolicy, phase]); + return Boolean(policy.phases[phase]?.actions?.forcemerge); + }, [policy, phase]); return ( = ({ phase }) => { - const phaseIndexPriorityProperty = propertyof('phaseIndexPriority'); return ( = ({ phase }) => { componentProps={{ fullWidth: false, euiFieldProps: { - 'data-test-subj': `${phase}-${phaseIndexPriorityProperty}`, - min: 1, + 'data-test-subj': `${phase}-phaseIndexPriority`, + min: 0, }, }} /> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/snapshot_policies.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx similarity index 68% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/snapshot_policies.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx index cc2849b5c8e9c..e9f9f331e410a 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/snapshot_policies.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx @@ -4,52 +4,39 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment } from 'react'; - +import React from 'react'; +import { get } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { ApplicationStart } from 'kibana/public'; import { EuiButtonIcon, EuiCallOut, - EuiComboBox, EuiComboBoxOptionOption, EuiLink, EuiSpacer, } from '@elastic/eui'; -import { useLoadSnapshotPolicies } from '../../../services/api'; +import { UseField, ComboBoxField, useFormData } from '../../../../../../shared_imports'; +import { useLoadSnapshotPolicies } from '../../../../../services/api'; +import { useEditPolicyContext } from '../../../edit_policy_context'; + +const waitForSnapshotFormField = 'phases.delete.actions.wait_for_snapshot.policy'; -interface Props { - value: string; - onChange: (value: string) => void; - getUrlForApp: ApplicationStart['getUrlForApp']; -} -export const SnapshotPolicies: React.FunctionComponent = ({ - value, - onChange, - getUrlForApp, -}) => { +export const SnapshotPoliciesField: React.FunctionComponent = () => { + const { getUrlForApp } = useEditPolicyContext(); const { error, isLoading, data, resendRequest } = useLoadSnapshotPolicies(); + const [formData] = useFormData({ + watch: waitForSnapshotFormField, + }); + + const selectedSnapshotPolicy = get(formData, waitForSnapshotFormField); const policies = data.map((name: string) => ({ label: name, value: name, })); - const onComboChange = (options: EuiComboBoxOptionOption[]) => { - if (options.length > 0) { - onChange(options[0].label); - } else { - onChange(''); - } - }; - - const onCreateOption = (newValue: string) => { - onChange(newValue); - }; - const getUrlForSnapshotPolicyWizard = () => { return getUrlForApp('management', { path: `data/snapshot_restore/add_policy`, @@ -59,14 +46,14 @@ export const SnapshotPolicies: React.FunctionComponent = ({ let calloutContent; if (error) { calloutContent = ( - + <> + <> = ({ } )} /> - + } > = ({ defaultMessage="Refresh this field and enter the name of an existing snapshot policy." /> - + ); } else if (data.length === 0) { calloutContent = ( - + <> = ({ }} /> - + ); - } else if (value && !data.includes(value)) { + } else if (selectedSnapshotPolicy && !data.includes(selectedSnapshotPolicy)) { calloutContent = ( - + <> = ({ }} /> - + ); } return ( - - + path={waitForSnapshotFormField}> + {(field) => { + const singleSelectionArray: [selectedSnapshot?: string] = field.value + ? [field.value] + : []; + + return ( + { + field.setValue(newOption); }, - ] - : [] - } - onChange={onComboChange} - noSuggestions={!!(error || data.length === 0)} - /> + onChange: (options: EuiComboBoxOptionOption[]) => { + if (options.length > 0) { + field.setValue(options[0].label); + } else { + field.setValue(''); + } + }, + }} + /> + ); + }} +
{calloutContent} - + ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx index 06c16e8bdd5ab..94fd2ee9edaca 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx @@ -17,23 +17,17 @@ import { EuiDescribedFormGroup, } from '@elastic/eui'; -import { - useFormData, - UseField, - ToggleField, - useFormContext, - NumericField, -} from '../../../../../../shared_imports'; +import { useFormData, UseField, ToggleField, NumericField } from '../../../../../../shared_imports'; import { Phases } from '../../../../../../../common/types'; -import { useRolloverPath, MinAgeInputField, Forcemerge, SetPriorityInput } from '../shared'; +import { useRolloverPath, MinAgeInputField, Forcemerge, SetPriorityInput } from '../shared_fields'; import { useEditPolicyContext } from '../../../edit_policy_context'; -import { LearnMoreLink, ActiveBadge, PhaseErrorMessage, DescribedFormField } from '../../'; +import { LearnMoreLink, ActiveBadge, DescribedFormField } from '../../'; -import { DataTierAllocationField } from '../shared'; +import { DataTierAllocationField } from '../shared_fields'; const i18nTexts = { shrinkLabel: i18n.translate('xpack.indexLifecycleMgmt.warmPhase.shrinkIndexLabel', { @@ -54,8 +48,7 @@ const formFieldPaths = { }; export const WarmPhase: FunctionComponent = () => { - const { originalPolicy } = useEditPolicyContext(); - const form = useFormContext(); + const { policy } = useEditPolicyContext(); const [formData] = useFormData({ watch: [useRolloverPath, formFieldPaths.enabled, formFieldPaths.warmPhaseOnRollover], }); @@ -63,7 +56,6 @@ export const WarmPhase: FunctionComponent = () => { const enabled = get(formData, formFieldPaths.enabled); const hotPhaseRolloverEnabled = get(formData, useRolloverPath); const warmPhaseOnRollover = get(formData, formFieldPaths.warmPhaseOnRollover); - const isShowingErrors = form.isValid === false; return (
@@ -77,8 +69,7 @@ export const WarmPhase: FunctionComponent = () => { defaultMessage="Warm phase" /> {' '} - {enabled && !isShowingErrors ? : null} - + {enabled && }
} titleSize="s" @@ -161,9 +152,7 @@ export const WarmPhase: FunctionComponent = () => { 'xpack.indexLifecycleMgmt.editPolicy.warmPhase.numberOfReplicas.switchLabel', { defaultMessage: 'Set replicas' } ), - initialValue: Boolean( - originalPolicy.phases.warm?.actions?.allocate?.number_of_replicas - ), + initialValue: Boolean(policy.phases.warm?.actions?.allocate?.number_of_replicas), }} fullWidth > @@ -203,7 +192,7 @@ export const WarmPhase: FunctionComponent = () => { 'data-test-subj': 'shrinkSwitch', label: i18nTexts.shrinkLabel, 'aria-label': i18nTexts.shrinkLabel, - initialValue: Boolean(originalPolicy.phases.warm?.actions?.shrink), + initialValue: Boolean(policy.phases.warm?.actions?.shrink), }} fullWidth > diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/policy_json_flyout.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/policy_json_flyout.tsx index 7098b018d6dfd..a8b1680ebde07 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/policy_json_flyout.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/policy_json_flyout.tsx @@ -7,7 +7,6 @@ import React, { useCallback, useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; - import { EuiButtonEmpty, EuiCodeBlock, @@ -25,19 +24,15 @@ import { import { SerializedPolicy } from '../../../../../common/types'; import { useFormContext, useFormData } from '../../../../shared_imports'; + import { FormInternal } from '../types'; interface Props { - legacyPolicy: SerializedPolicy; close: () => void; policyName: string; } -export const PolicyJsonFlyout: React.FunctionComponent = ({ - policyName, - close, - legacyPolicy, -}) => { +export const PolicyJsonFlyout: React.FunctionComponent = ({ policyName, close }) => { /** * policy === undefined: we are checking validity * policy === null: we have determined the policy is invalid @@ -51,20 +46,11 @@ export const PolicyJsonFlyout: React.FunctionComponent = ({ const updatePolicy = useCallback(async () => { setPolicy(undefined); if (await validateForm()) { - const p = getFormData() as SerializedPolicy; - setPolicy({ - ...legacyPolicy, - phases: { - ...legacyPolicy.phases, - hot: p.phases.hot, - warm: p.phases.warm, - cold: p.phases.cold, - }, - }); + setPolicy(getFormData() as SerializedPolicy); } else { setPolicy(null); } - }, [setPolicy, getFormData, legacyPolicy, validateForm]); + }, [setPolicy, getFormData, validateForm]); useEffect(() => { updatePolicy(); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx index c82a420b74857..ebef80871b83d 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx @@ -12,8 +12,11 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { useKibana } from '../../../shared_imports'; import { useLoadPoliciesList } from '../../services/api'; +import { getPolicyByName } from '../../lib/policies'; +import { defaultPolicy } from '../../constants'; import { EditPolicy as PresentationComponent } from './edit_policy'; +import { EditPolicyContextProvider } from './edit_policy_context'; interface RouterProps { policyName: string; @@ -44,6 +47,7 @@ export const EditPolicy: React.FunctionComponent { breadcrumbService.setBreadcrumbs('editPolicy'); }, [breadcrumbService]); + if (isLoading) { return ( + + + ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx index 5397f5da2d6bb..1abbe884c2dc2 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, useEffect, useState, useCallback, useMemo } from 'react'; +import React, { Fragment, useEffect, useState, useMemo } from 'react'; +import { get } from 'lodash'; import { RouteComponentProps } from 'react-router-dom'; @@ -16,7 +17,6 @@ import { EuiButton, EuiButtonEmpty, EuiDescribedFormGroup, - EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiFormRow, @@ -30,31 +30,13 @@ import { EuiTitle, } from '@elastic/eui'; -import { useForm, Form } from '../../../shared_imports'; +import { useForm, Form, UseField, TextField, useFormData } from '../../../shared_imports'; import { toasts } from '../../services/notification'; -import { LegacyPolicy, PolicyFromES, SerializedPolicy } from '../../../../common/types'; - -import { defaultPolicy } from '../../constants'; - -import { - validatePolicy, - ValidationErrors, - findFirstError, -} from '../../services/policies/policy_validation'; - -import { savePolicy } from '../../services/policies/policy_save'; +import { savePolicy } from './save_policy'; import { - deserializePolicy, - getPolicyByName, - initializeNewPolicy, - legacySerializePolicy, -} from '../../services/policies/policy_serialization'; - -import { - ErrableFormRow, LearnMoreLink, PolicyJsonFlyout, ColdPhase, @@ -63,93 +45,66 @@ import { WarmPhase, } from './components'; -import { schema } from './form_schema'; -import { deserializer } from './deserializer'; -import { createSerializer } from './serializer'; +import { schema, deserializer, createSerializer, createPolicyNameValidations } from './form'; -import { EditPolicyContextProvider } from './edit_policy_context'; +import { useEditPolicyContext } from './edit_policy_context'; +import { FormInternal } from './types'; export interface Props { - policies: PolicyFromES[]; - policyName: string; - getUrlForApp: ( - appId: string, - options?: { - path?: string; - absolute?: boolean; - } - ) => string; history: RouteComponentProps['history']; } -const mergeAllSerializedPolicies = ( - serializedPolicy: SerializedPolicy, - legacySerializedPolicy: SerializedPolicy -): SerializedPolicy => { - return { - ...legacySerializedPolicy, - phases: { - ...legacySerializedPolicy.phases, - hot: serializedPolicy.phases.hot, - warm: serializedPolicy.phases.warm, - cold: serializedPolicy.phases.cold, - }, - }; -}; +const policyNamePath = 'name'; -export const EditPolicy: React.FunctionComponent = ({ - policies, - policyName, - history, - getUrlForApp, -}) => { +export const EditPolicy: React.FunctionComponent = ({ history }) => { useEffect(() => { window.scrollTo(0, 0); }, []); - const [isShowingErrors, setIsShowingErrors] = useState(false); - const [errors, setErrors] = useState(); const [isShowingPolicyJsonFlyout, setIsShowingPolicyJsonFlyout] = useState(false); - - const existingPolicy = getPolicyByName(policies, policyName); + const { + isNewPolicy, + policy: currentPolicy, + existingPolicies, + policyName, + } = useEditPolicyContext(); const serializer = useMemo(() => { - return createSerializer(existingPolicy?.policy); - }, [existingPolicy?.policy]); + return createSerializer(isNewPolicy ? undefined : currentPolicy); + }, [isNewPolicy, currentPolicy]); - const originalPolicy = existingPolicy?.policy ?? defaultPolicy; + const [saveAsNew, setSaveAsNew] = useState(isNewPolicy); + const originalPolicyName: string = isNewPolicy ? '' : policyName!; const { form } = useForm({ schema, - defaultValue: originalPolicy, + defaultValue: { + ...currentPolicy, + name: originalPolicyName, + }, deserializer, serializer, }); - const [policy, setPolicy] = useState(() => - existingPolicy ? deserializePolicy(existingPolicy) : initializeNewPolicy(policyName) + const [formData] = useFormData({ form, watch: policyNamePath }); + const currentPolicyName = get(formData, policyNamePath); + + const policyNameValidations = useMemo( + () => + createPolicyNameValidations({ + originalPolicyName, + policies: existingPolicies, + saveAsNewPolicy: saveAsNew, + }), + [originalPolicyName, existingPolicies, saveAsNew] ); - const isNewPolicy: boolean = !Boolean(existingPolicy); - const [saveAsNew, setSaveAsNew] = useState(isNewPolicy); - const originalPolicyName: string = existingPolicy ? existingPolicy.name : ''; - const backToPolicyList = () => { history.push('/policies'); }; const submit = async () => { - setIsShowingErrors(true); - const { data: formLibPolicy, isValid: newIsValid } = await form.submit(); - const [legacyIsValid, validationErrors] = validatePolicy( - saveAsNew, - policy, - policies, - originalPolicyName - ); - setErrors(validationErrors); - - const isValid = legacyIsValid && newIsValid; + const { data: policy, isValid } = await form.submit(); if (!isValid) { toasts.addDanger( @@ -157,22 +112,11 @@ export const EditPolicy: React.FunctionComponent = ({ defaultMessage: 'Please fix the errors on this page.', }) ); - // This functionality will not be required for once form lib is fully adopted for this form - // because errors are reported as fields are edited. - if (!legacyIsValid) { - const firstError = findFirstError(validationErrors); - const errorRowId = `${firstError ? firstError.replace('.', '-') : ''}-row`; - const element = document.getElementById(errorRowId); - if (element) { - element.scrollIntoView({ block: 'center', inline: 'nearest' }); - } - } } else { - const readSerializedPolicy = () => { - const legacySerializedPolicy = legacySerializePolicy(policy, existingPolicy?.policy); - return mergeAllSerializedPolicies(formLibPolicy, legacySerializedPolicy); - }; - const success = await savePolicy(readSerializedPolicy, isNewPolicy || saveAsNew); + const success = await savePolicy( + { ...policy, name: saveAsNew ? currentPolicyName : originalPolicyName }, + isNewPolicy || saveAsNew + ); if (success) { backToPolicyList(); } @@ -183,248 +127,217 @@ export const EditPolicy: React.FunctionComponent = ({ setIsShowingPolicyJsonFlyout(!isShowingPolicyJsonFlyout); }; - const setPhaseData = useCallback( - (phase: keyof LegacyPolicy['phases'], key: string, value: any) => { - setPolicy((nextPolicy) => ({ - ...nextPolicy, - phases: { - ...nextPolicy.phases, - [phase]: { ...nextPolicy.phases[phase], [key]: value }, - }, - })); - }, - [setPolicy] - ); - - const setDeletePhaseData = useCallback( - (key: string, value: any) => setPhaseData('delete', key, value), - [setPhaseData] - ); - return ( - - - - - -

- {isNewPolicy - ? i18n.translate('xpack.indexLifecycleMgmt.editPolicy.createPolicyMessage', { - defaultMessage: 'Create an index lifecycle policy', - }) - : i18n.translate('xpack.indexLifecycleMgmt.editPolicy.editPolicyMessage', { - defaultMessage: 'Edit index lifecycle policy {originalPolicyName}', - values: { originalPolicyName }, - })} -

-
- -
-
- - -

- + +

+ {isNewPolicy + ? i18n.translate('xpack.indexLifecycleMgmt.editPolicy.createPolicyMessage', { + defaultMessage: 'Create an index lifecycle policy', + }) + : i18n.translate('xpack.indexLifecycleMgmt.editPolicy.editPolicyMessage', { + defaultMessage: 'Edit index lifecycle policy {originalPolicyName}', + values: { originalPolicyName }, + })} +

+ + +
+ + + +

+ {' '} - - } - /> -

-
+ />{' '} + + } + /> +

+ - + - {isNewPolicy ? null : ( - - -

- - - - .{' '} + {isNewPolicy ? null : ( + + +

+ + + .{' '} + -

-
- - - - { - setSaveAsNew(e.target.checked); - }} - label={ - - - - } /> - -
- )} - - {saveAsNew || isNewPolicy ? ( - - +

+ + + + + { + setSaveAsNew(e.target.checked); + }} + label={ + -
- } - titleSize="s" - fullWidth - > - + + + )} + + {saveAsNew || isNewPolicy ? ( + + - } - > - { - setPolicy({ ...policy, name: e.target.value }); - }} - /> - - - ) : null} + +
+ } + titleSize="s" + fullWidth + > + + path={policyNamePath} + config={{ + label: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.policyNameLabel', { + defaultMessage: 'Policy name', + }), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.validPolicyNameMessage', + { + defaultMessage: + 'A policy name cannot start with an underscore and cannot contain a question mark or a space.', + } + ), + validations: policyNameValidations, + }} + component={TextField} + componentProps={{ + fullWidth: false, + euiFieldProps: { + 'data-test-subj': 'policyNameField', + }, + }} + /> + + ) : null} - + - + - + - + - + - + - + - 0 - } - getUrlForApp={getUrlForApp} - setPhaseData={setDeletePhaseData} - phaseData={policy.phases.delete} - /> + - - - - - - - - {saveAsNew ? ( - - ) : ( - - )} - - - - - + + + + + + + + {saveAsNew ? ( - - - - - - - - {isShowingPolicyJsonFlyout ? ( - - ) : ( + ) : ( + + )} + + + + + - )} - - - - - {isShowingPolicyJsonFlyout ? ( - setIsShowingPolicyJsonFlyout(false)} - /> - ) : null} - -
- - - - + + + + + + + + {isShowingPolicyJsonFlyout ? ( + + ) : ( + + )} + + + + + {isShowingPolicyJsonFlyout ? ( + setIsShowingPolicyJsonFlyout(false)} + /> + ) : null} + + + + + ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy_context.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy_context.tsx index 4748c26d6cec1..da5f940b1b6c8 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy_context.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy_context.tsx @@ -5,10 +5,16 @@ */ import React, { createContext, ReactChild, useContext } from 'react'; -import { SerializedPolicy } from '../../../../common/types'; +import { ApplicationStart } from 'kibana/public'; -interface EditPolicyContextValue { - originalPolicy: SerializedPolicy; +import { PolicyFromES, SerializedPolicy } from '../../../../common/types'; + +export interface EditPolicyContextValue { + isNewPolicy: boolean; + policy: SerializedPolicy; + existingPolicies: PolicyFromES[]; + getUrlForApp: ApplicationStart['getUrlForApp']; + policyName?: string; } const EditPolicyContext = createContext(null as any); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/deserializer.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts similarity index 82% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/deserializer.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts index f0294a5391d21..5af8807f2dec8 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/deserializer.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts @@ -6,17 +6,17 @@ import { produce } from 'immer'; -import { SerializedPolicy } from '../../../../common/types'; +import { SerializedPolicy } from '../../../../../common/types'; -import { splitSizeAndUnits } from '../../services/policies/policy_serialization'; +import { splitSizeAndUnits } from '../../../lib/policies'; -import { determineDataTierAllocationType } from '../../lib'; +import { determineDataTierAllocationType } from '../../../lib'; -import { FormInternal } from './types'; +import { FormInternal } from '../types'; export const deserializer = (policy: SerializedPolicy): FormInternal => { const { - phases: { hot, warm, cold }, + phases: { hot, warm, cold, delete: deletePhase }, } = policy; const _meta: FormInternal['_meta'] = { @@ -37,6 +37,9 @@ export const deserializer = (policy: SerializedPolicy): FormInternal => { dataTierAllocationType: determineDataTierAllocationType(cold?.actions), freezeEnabled: Boolean(cold?.actions?.freeze), }, + delete: { + enabled: Boolean(deletePhase), + }, }; return produce( @@ -86,6 +89,14 @@ export const deserializer = (policy: SerializedPolicy): FormInternal => { draft._meta.cold.minAgeUnit = minAge.units; } } + + if (draft.phases.delete) { + if (draft.phases.delete.min_age) { + const minAge = splitSizeAndUnits(draft.phases.delete.min_age); + draft.phases.delete.min_age = minAge.size; + draft._meta.delete.minAgeUnit = minAge.units; + } + } } ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/index.ts new file mode 100644 index 0000000000000..82fa478832582 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { deserializer } from './deserializer'; + +export { createSerializer } from './serializer'; + +export { schema } from './schema'; + +export * from './validations'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form_schema.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts similarity index 90% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form_schema.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts index 070f03f74b954..4d20db4018740 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form_schema.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts @@ -6,18 +6,19 @@ import { i18n } from '@kbn/i18n'; -import { FormSchema, fieldValidators } from '../../../shared_imports'; -import { defaultSetPriority, defaultPhaseIndexPriority } from '../../constants'; +import { FormSchema, fieldValidators } from '../../../../shared_imports'; +import { defaultSetPriority, defaultPhaseIndexPriority } from '../../../constants'; -import { FormInternal } from './types'; +import { FormInternal } from '../types'; import { ifExistsNumberGreaterThanZero, ifExistsNumberNonNegative, rolloverThresholdsValidator, -} from './form_validations'; + minAgeValidator, +} from './validations'; -import { i18nTexts } from './i18n_texts'; +import { i18nTexts } from '../i18n_texts'; const { emptyField, numberGreaterThanField } = fieldValidators; @@ -97,6 +98,18 @@ export const schema: FormSchema = { label: i18nTexts.editPolicy.allocationNodeAttributeFieldLabel, }, }, + delete: { + enabled: { + defaultValue: false, + label: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.deletePhase.activateWarmPhaseSwitchLabel', + { defaultMessage: 'Activate delete phase' } + ), + }, + minAgeUnit: { + defaultValue: 'd', + }, + }, }, phases: { hot: { @@ -177,15 +190,7 @@ export const schema: FormSchema = { defaultValue: '0', validations: [ { - validator: (arg) => - numberGreaterThanField({ - than: 0, - allowEquality: true, - message: i18nTexts.editPolicy.errors.nonNegativeNumberRequired, - })({ - ...arg, - value: arg.value === '' ? -Infinity : parseInt(arg.value, 10), - }), + validator: minAgeValidator, }, ], }, @@ -256,15 +261,7 @@ export const schema: FormSchema = { defaultValue: '0', validations: [ { - validator: (arg) => - numberGreaterThanField({ - than: 0, - allowEquality: true, - message: i18nTexts.editPolicy.errors.nonNegativeNumberRequired, - })({ - ...arg, - value: arg.value === '' ? -Infinity : parseInt(arg.value, 10), - }), + validator: minAgeValidator, }, ], }, @@ -292,5 +289,15 @@ export const schema: FormSchema = { }, }, }, + delete: { + min_age: { + defaultValue: '0', + validations: [ + { + validator: minAgeValidator, + }, + ], + }, + }, }, }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/serializer.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer.ts similarity index 90% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/serializer.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer.ts index 564b5a2c4e397..2274efda426ad 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/serializer.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer.ts @@ -4,12 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isEmpty } from 'lodash'; +import { isEmpty, isNumber } from 'lodash'; -import { SerializedPolicy, SerializedActionWithAllocation } from '../../../../common/types'; +import { SerializedPolicy, SerializedActionWithAllocation } from '../../../../../common/types'; -import { FormInternal, DataAllocationMetaFields } from './types'; -import { isNumber } from '../../services/policies/policy_serialization'; +import { FormInternal, DataAllocationMetaFields } from '../types'; const serializeAllocateAction = ( { dataTierAllocationType, allocationNodeAttribute }: DataAllocationMetaFields, @@ -165,5 +164,22 @@ export const createSerializer = (originalPolicy?: SerializedPolicy) => ( } } + /** + * DELETE PHASE SERIALIZATION + */ + if (policy.phases.delete) { + if (policy.phases.delete.min_age) { + policy.phases.delete.min_age = `${policy.phases.delete.min_age}${_meta.delete.minAgeUnit}`; + } + + if (originalPolicy?.phases.delete?.actions) { + const { wait_for_snapshot: __, ...rest } = originalPolicy.phases.delete.actions; + policy.phases.delete.actions = { + ...policy.phases.delete.actions, + ...rest, + }; + } + } + return policy; }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form_validations.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/validations.ts similarity index 50% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form_validations.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/validations.ts index 9c855ccb41624..f2e26a552efc9 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form_validations.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/validations.ts @@ -4,13 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { fieldValidators, ValidationFunc } from '../../../shared_imports'; +import { fieldValidators, ValidationFunc, ValidationConfig } from '../../../../shared_imports'; -import { ROLLOVER_FORM_PATHS } from './constants'; +import { ROLLOVER_FORM_PATHS } from '../constants'; -import { i18nTexts } from './i18n_texts'; +import { i18nTexts } from '../i18n_texts'; +import { PolicyFromES } from '../../../../../common/types'; +import { FormInternal } from '../types'; -const { numberGreaterThanField } = fieldValidators; +const { numberGreaterThanField, containsCharsField, emptyField, startsWithField } = fieldValidators; const createIfNumberExistsValidator = ({ than, @@ -46,7 +48,7 @@ export const ifExistsNumberNonNegative = createIfNumberExistsValidator({ * A special validation type used to keep track of validation errors for * the rollover threshold values not being set (e.g., age and doc count) */ -export const ROLLOVER_EMPTY_VALIDATION = 'EMPTY'; +export const ROLLOVER_EMPTY_VALIDATION = 'ROLLOVER_EMPTY_VALIDATION'; /** * An ILM policy requires that for rollover a value must be set for one of the threshold values. @@ -87,3 +89,68 @@ export const rolloverThresholdsValidator: ValidationFunc = ({ form }) => { fields[ROLLOVER_FORM_PATHS.maxSize].clearErrors(ROLLOVER_EMPTY_VALIDATION); } }; + +export const minAgeValidator: ValidationFunc = (arg) => + numberGreaterThanField({ + than: 0, + allowEquality: true, + message: i18nTexts.editPolicy.errors.nonNegativeNumberRequired, + })({ + ...arg, + value: arg.value === '' ? -Infinity : parseInt(arg.value, 10), + }); + +export const createPolicyNameValidations = ({ + policies, + saveAsNewPolicy, + originalPolicyName, +}: { + policies: PolicyFromES[]; + saveAsNewPolicy: boolean; + originalPolicyName?: string; +}): Array> => { + return [ + { + validator: emptyField(i18nTexts.editPolicy.errors.policyNameRequiredMessage), + }, + { + validator: startsWithField({ + message: i18nTexts.editPolicy.errors.policyNameStartsWithUnderscoreErrorMessage, + char: '_', + }), + }, + { + validator: containsCharsField({ + message: i18nTexts.editPolicy.errors.policyNameContainsInvalidChars, + chars: [',', ' '], + }), + }, + { + validator: (arg) => { + const policyName = arg.value; + if (window.TextEncoder && new window.TextEncoder().encode(policyName).length > 255) { + return { + message: i18nTexts.editPolicy.errors.policyNameTooLongErrorMessage, + }; + } + }, + }, + { + validator: (arg) => { + const policyName = arg.value; + if (saveAsNewPolicy && policyName === originalPolicyName) { + return { + message: i18nTexts.editPolicy.errors.policyNameMustBeDifferentErrorMessage, + }; + } else if (policyName !== originalPolicyName) { + const policyNames = policies.map((existingPolicy) => existingPolicy.name); + if (policyNames.includes(policyName)) { + return { + message: i18nTexts.editPolicy.errors.policyNameAlreadyUsedErrorMessage, + }; + } + } + }, + }, + ]; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts index 1fba69b7634ae..ccd5d3a568fe3 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts @@ -98,6 +98,42 @@ export const i18nTexts = { defaultMessage: 'Only non-negative numbers are allowed.', } ), + policyNameContainsInvalidChars: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.errors.policyNameContainsInvalidCharsError', + { + defaultMessage: 'A policy name cannot contain spaces or commas.', + } + ), + policyNameAlreadyUsedErrorMessage: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.policyNameAlreadyUsedError', + { + defaultMessage: 'That policy name is already used.', + } + ), + policyNameMustBeDifferentErrorMessage: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.differentPolicyNameRequiredError', + { + defaultMessage: 'The policy name must be different.', + } + ), + policyNameRequiredMessage: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.policyNameRequiredError', + { + defaultMessage: 'A policy name is required.', + } + ), + policyNameStartsWithUnderscoreErrorMessage: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.policyNameStartsWithUnderscoreError', + { + defaultMessage: 'A policy name cannot start with an underscore.', + } + ), + policyNameTooLongErrorMessage: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.policyNameTooLongError', + { + defaultMessage: 'A policy name cannot be longer than 255 bytes.', + } + ), }, }, }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_save.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/save_policy.ts similarity index 84% rename from x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_save.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/save_policy.ts index 9cf622e830cb2..e2ab6a8817ef6 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_save.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/save_policy.ts @@ -3,23 +3,22 @@ * 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 { METRIC_TYPE } from '@kbn/analytics'; import { SerializedPolicy } from '../../../../common/types'; -import { savePolicy as savePolicyApi } from '../api'; -import { showApiError } from '../api_errors'; -import { getUiMetricsForPhases, trackUiMetric } from '../ui_metric'; + import { UIM_POLICY_CREATE, UIM_POLICY_UPDATE } from '../../constants'; -import { toasts } from '../notification'; + +import { toasts } from '../../services/notification'; +import { savePolicy as savePolicyApi } from '../../services/api'; +import { getUiMetricsForPhases, trackUiMetric } from '../../services/ui_metric'; +import { showApiError } from '../../services/api_errors'; export const savePolicy = async ( - readSerializedPolicy: () => SerializedPolicy, + serializedPolicy: SerializedPolicy, isNew: boolean ): Promise => { - const serializedPolicy = readSerializedPolicy(); - try { await savePolicyApi(serializedPolicy); } catch (err) { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/types.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/types.ts index 1884f8dbc0619..dc3d8a640e682 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/types.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/types.ts @@ -38,6 +38,10 @@ interface ColdPhaseMetaFields extends DataAllocationMetaFields, MinAgeField { freezeEnabled: boolean; } +interface DeletePhaseMetaFields extends MinAgeField { + enabled: boolean; +} + /** * Describes the shape of data after deserialization. */ @@ -50,5 +54,6 @@ export interface FormInternal extends SerializedPolicy { hot: HotPhaseMetaFields; warm: WarmPhaseMetaFields; cold: ColdPhaseMetaFields; + delete: DeletePhaseMetaFields; }; } diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/delete_phase.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/delete_phase.ts deleted file mode 100644 index 6ada039d45cd9..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/delete_phase.ts +++ /dev/null @@ -1,88 +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 { DeletePhase, SerializedDeletePhase } from '../../../../common/types'; -import { serializedPhaseInitialization } from '../../constants'; -import { isNumber, splitSizeAndUnits } from './policy_serialization'; -import { - numberRequiredMessage, - PhaseValidationErrors, - positiveNumberRequiredMessage, -} from './policy_validation'; - -const deletePhaseInitialization: DeletePhase = { - phaseEnabled: false, - selectedMinimumAge: '0', - selectedMinimumAgeUnits: 'd', - waitForSnapshotPolicy: '', -}; - -export const deletePhaseFromES = (phaseSerialized?: SerializedDeletePhase): DeletePhase => { - const phase = { ...deletePhaseInitialization }; - if (phaseSerialized === undefined || phaseSerialized === null) { - return phase; - } - - phase.phaseEnabled = true; - if (phaseSerialized.min_age) { - const { size: minAge, units: minAgeUnits } = splitSizeAndUnits(phaseSerialized.min_age); - phase.selectedMinimumAge = minAge; - phase.selectedMinimumAgeUnits = minAgeUnits; - } - - if (phaseSerialized.actions) { - const actions = phaseSerialized.actions; - - if (actions.wait_for_snapshot) { - phase.waitForSnapshotPolicy = actions.wait_for_snapshot.policy; - } - } - - return phase; -}; - -export const deletePhaseToES = ( - phase: DeletePhase, - originalEsPhase?: SerializedDeletePhase -): SerializedDeletePhase => { - if (!originalEsPhase) { - originalEsPhase = { ...serializedPhaseInitialization }; - } - const esPhase = { ...originalEsPhase }; - - if (isNumber(phase.selectedMinimumAge)) { - esPhase.min_age = `${phase.selectedMinimumAge}${phase.selectedMinimumAgeUnits}`; - } - - esPhase.actions = esPhase.actions ? { ...esPhase.actions } : {}; - - if (phase.waitForSnapshotPolicy) { - esPhase.actions.wait_for_snapshot = { - policy: phase.waitForSnapshotPolicy, - }; - } else { - delete esPhase.actions.wait_for_snapshot; - } - - return esPhase; -}; - -export const validateDeletePhase = (phase: DeletePhase): PhaseValidationErrors => { - if (!phase.phaseEnabled) { - return {}; - } - - const phaseErrors = {} as PhaseValidationErrors; - - // min age needs to be a positive number - if (!isNumber(phase.selectedMinimumAge)) { - phaseErrors.selectedMinimumAge = [numberRequiredMessage]; - } else if (parseInt(phase.selectedMinimumAge, 10) < 0) { - phaseErrors.selectedMinimumAge = [positiveNumberRequiredMessage]; - } - - return { ...phaseErrors }; -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.test.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.test.ts deleted file mode 100644 index 19481b39a2c80..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.test.ts +++ /dev/null @@ -1,198 +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. - */ -// Prefer importing entire lodash library, e.g. import { get } from "lodash" -// eslint-disable-next-line no-restricted-imports -import cloneDeep from 'lodash/cloneDeep'; -import { deserializePolicy, legacySerializePolicy } from './policy_serialization'; -import { defaultNewDeletePhase } from '../../constants'; - -describe('Policy serialization', () => { - test('serialize a policy using "default" data allocation', () => { - expect( - legacySerializePolicy( - { - name: 'test', - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }, - { - name: 'test', - phases: { - hot: { actions: {} }, - }, - } - ) - ).toEqual({ - name: 'test', - phases: {}, - }); - }); - - test('serialize a policy using "custom" data allocation', () => { - expect( - legacySerializePolicy( - { - name: 'test', - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }, - { - name: 'test', - phases: { - hot: { actions: {} }, - }, - } - ) - ).toEqual({ - name: 'test', - phases: {}, - }); - }); - - test('serialize a policy using "custom" data allocation with no node attributes', () => { - expect( - legacySerializePolicy( - { - name: 'test', - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }, - { - name: 'test', - phases: { - hot: { actions: {} }, - }, - } - ) - ).toEqual({ - // There should be no allocation action in any phases... - name: 'test', - phases: {}, - }); - }); - - test('serialize a policy using "none" data allocation with no node attributes', () => { - expect( - legacySerializePolicy( - { - name: 'test', - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }, - { - name: 'test', - phases: { - hot: { actions: {} }, - }, - } - ) - ).toEqual({ - // There should be no allocation action in any phases... - name: 'test', - phases: {}, - }); - }); - - test('serialization does not alter the original policy', () => { - const originalPolicy = { - name: 'test', - phases: {}, - }; - - const originalClone = cloneDeep(originalPolicy); - - const deserializedPolicy = { - name: 'test', - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }; - - legacySerializePolicy(deserializedPolicy, originalPolicy); - expect(originalPolicy).toEqual(originalClone); - }); - - test('serialize a policy using "best_compression" codec for forcemerge', () => { - expect( - legacySerializePolicy( - { - name: 'test', - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }, - { - name: 'test', - phases: { - hot: { actions: {} }, - }, - } - ) - ).toEqual({ - name: 'test', - phases: {}, - }); - }); - - test('de-serialize a policy using "best_compression" codec for forcemerge', () => { - expect( - deserializePolicy({ - modified_date: Date.now().toString(), - name: 'test', - version: 1, - policy: { - name: 'test', - phases: { - hot: { - actions: { - rollover: { - max_age: '30d', - max_size: '50gb', - }, - forcemerge: { - max_num_segments: 1, - index_codec: 'best_compression', - }, - set_priority: { - priority: 100, - }, - }, - }, - }, - }, - }) - ).toEqual({ - name: 'test', - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }); - }); - - test('delete "best_compression" codec for forcemerge if disabled in UI', () => { - expect( - legacySerializePolicy( - { - name: 'test', - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }, - { - name: 'test', - phases: {}, - } - ) - ).toEqual({ - name: 'test', - phases: {}, - }); - }); -}); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts deleted file mode 100644 index 55e9d88dcd383..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts +++ /dev/null @@ -1,82 +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 { LegacyPolicy, PolicyFromES, SerializedPolicy } from '../../../../common/types'; - -import { defaultNewDeletePhase, serializedPhaseInitialization } from '../../constants'; - -import { deletePhaseFromES, deletePhaseToES } from './delete_phase'; - -export const splitSizeAndUnits = (field: string): { size: string; units: string } => { - let size = ''; - let units = ''; - - const result = /(\d+)(\w+)/.exec(field); - if (result) { - size = result[1]; - units = result[2]; - } - - return { - size, - units, - }; -}; - -export const isNumber = (value: any): boolean => value !== '' && value !== null && isFinite(value); - -export const getPolicyByName = ( - policies: PolicyFromES[] | null | undefined, - policyName: string = '' -): PolicyFromES | undefined => { - if (policies && policies.length > 0) { - return policies.find((policy: PolicyFromES) => policy.name === policyName); - } -}; - -export const initializeNewPolicy = (newPolicyName: string = ''): LegacyPolicy => { - return { - name: newPolicyName, - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }; -}; - -export const deserializePolicy = (policy: PolicyFromES): LegacyPolicy => { - const { - name, - policy: { phases }, - } = policy; - - return { - name, - phases: { - delete: deletePhaseFromES(phases.delete), - }, - }; -}; - -export const legacySerializePolicy = ( - policy: LegacyPolicy, - originalEsPolicy: SerializedPolicy = { - name: policy.name, - phases: { hot: { ...serializedPhaseInitialization } }, - } -): SerializedPolicy => { - const serializedPolicy = { - name: policy.name, - phases: {}, - } as SerializedPolicy; - - if (policy.phases.delete.phaseEnabled) { - serializedPolicy.phases.delete = deletePhaseToES( - policy.phases.delete, - originalEsPolicy.phases.delete - ); - } - return serializedPolicy; -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts deleted file mode 100644 index 79c909c433f33..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts +++ /dev/null @@ -1,144 +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 { i18n } from '@kbn/i18n'; -import { DeletePhase, LegacyPolicy, PolicyFromES } from '../../../../common/types'; -import { validateDeletePhase } from './delete_phase'; - -export const propertyof = (propertyName: keyof T & string) => propertyName; - -export const numberRequiredMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.numberRequiredError', - { - defaultMessage: 'A number is required.', - } -); - -// TODO validation includes 0 -> should be non-negative number? -export const positiveNumberRequiredMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.positiveNumberRequiredError', - { - defaultMessage: 'Only positive numbers are allowed.', - } -); - -export const positiveNumbersAboveZeroErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.positiveNumberAboveZeroRequiredError', - { - defaultMessage: 'Only numbers above 0 are allowed.', - } -); - -export const policyNameRequiredMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameRequiredError', - { - defaultMessage: 'A policy name is required.', - } -); - -export const policyNameStartsWithUnderscoreErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameStartsWithUnderscoreError', - { - defaultMessage: 'A policy name cannot start with an underscore.', - } -); -export const policyNameContainsCommaErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameContainsCommaError', - { - defaultMessage: 'A policy name cannot include a comma.', - } -); -export const policyNameContainsSpaceErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameContainsSpaceError', - { - defaultMessage: 'A policy name cannot include a space.', - } -); - -export const policyNameTooLongErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameTooLongError', - { - defaultMessage: 'A policy name cannot be longer than 255 bytes.', - } -); -export const policyNameMustBeDifferentErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.differentPolicyNameRequiredError', - { - defaultMessage: 'The policy name must be different.', - } -); -export const policyNameAlreadyUsedErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameAlreadyUsedError', - { - defaultMessage: 'That policy name is already used.', - } -); -export type PhaseValidationErrors = { - [P in keyof Partial]: string[]; -}; - -export interface ValidationErrors { - delete: PhaseValidationErrors; - policyName: string[]; -} - -export const validatePolicy = ( - saveAsNew: boolean, - policy: LegacyPolicy, - policies: PolicyFromES[], - originalPolicyName: string -): [boolean, ValidationErrors] => { - const policyNameErrors: string[] = []; - if (!policy.name) { - policyNameErrors.push(policyNameRequiredMessage); - } else { - if (policy.name.startsWith('_')) { - policyNameErrors.push(policyNameStartsWithUnderscoreErrorMessage); - } - if (policy.name.includes(',')) { - policyNameErrors.push(policyNameContainsCommaErrorMessage); - } - if (policy.name.includes(' ')) { - policyNameErrors.push(policyNameContainsSpaceErrorMessage); - } - if (window.TextEncoder && new window.TextEncoder().encode(policy.name).length > 255) { - policyNameErrors.push(policyNameTooLongErrorMessage); - } - - if (saveAsNew && policy.name === originalPolicyName) { - policyNameErrors.push(policyNameMustBeDifferentErrorMessage); - } else if (policy.name !== originalPolicyName) { - const policyNames = policies.map((existingPolicy) => existingPolicy.name); - if (policyNames.includes(policy.name)) { - policyNameErrors.push(policyNameAlreadyUsedErrorMessage); - } - } - } - - const deletePhaseErrors = validateDeletePhase(policy.phases.delete); - const isValid = policyNameErrors.length === 0 && Object.keys(deletePhaseErrors).length === 0; - return [ - isValid, - { - policyName: [...policyNameErrors], - delete: deletePhaseErrors, - }, - ]; -}; - -export const findFirstError = (errors?: ValidationErrors): string | undefined => { - if (!errors) { - return; - } - - if (errors.policyName.length > 0) { - return propertyof('policyName'); - } - - if (Object.keys(errors.delete).length > 0) { - return `${propertyof('delete')}.${Object.keys(errors.delete)[0]}`; - } -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts b/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts index 023aeba57aa7a..a127574d5bad0 100644 --- a/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts +++ b/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts @@ -18,6 +18,7 @@ export { getFieldValidityAndErrorMessage, useFormContext, FormSchema, + ValidationConfig, } from '../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; export { fieldValidators } from '../../../../src/plugins/es_ui_shared/static/forms/helpers'; @@ -27,6 +28,8 @@ export { NumericField, SelectField, SuperSelectField, + ComboBoxField, + TextField, } from '../../../../src/plugins/es_ui_shared/static/forms/components'; export { KibanaContextProvider } from '../../../../src/plugins/kibana_react/public'; diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts index 4c8c610794b2e..214bb16b24283 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { useEffect, useState, useReducer, useCallback } from 'react'; +import { useMountedState } from 'react-use'; import createContainer from 'constate'; import { pick, throttle } from 'lodash'; import { TimeKey, timeKeyIsBetween } from '../../../../common/time'; @@ -146,15 +147,20 @@ const useFetchEntriesEffect = ( props: LogEntriesProps ) => { const { services } = useKibanaContextForPlugin(); + const isMounted = useMountedState(); const [prevParams, cachePrevParams] = useState(); const [startedStreaming, setStartedStreaming] = useState(false); + const dispatchIfMounted = useCallback((action) => (isMounted() ? dispatch(action) : undefined), [ + dispatch, + isMounted, + ]); const runFetchNewEntriesRequest = async (overrides: Partial = {}) => { if (!props.startTimestamp || !props.endTimestamp) { return; } - dispatch({ type: Action.FetchingNewEntries }); + dispatchIfMounted({ type: Action.FetchingNewEntries }); try { const commonFetchArgs: LogEntriesBaseRequest = { @@ -175,13 +181,15 @@ const useFetchEntriesEffect = ( }; const { data: payload } = await fetchLogEntries(fetchArgs, services.http.fetch); - dispatch({ type: Action.ReceiveNewEntries, payload }); + dispatchIfMounted({ type: Action.ReceiveNewEntries, payload }); // Move position to the bottom if it's the first load. // Do it in the next tick to allow the `dispatch` to fire if (!props.timeKey && payload.bottomCursor) { setTimeout(() => { - props.jumpToTargetPosition(payload.bottomCursor!); + if (isMounted()) { + props.jumpToTargetPosition(payload.bottomCursor!); + } }); } else if ( props.timeKey && @@ -192,7 +200,7 @@ const useFetchEntriesEffect = ( props.jumpToTargetPosition(payload.topCursor); } } catch (e) { - dispatch({ type: Action.ErrorOnNewEntries }); + dispatchIfMounted({ type: Action.ErrorOnNewEntries }); } }; @@ -210,7 +218,7 @@ const useFetchEntriesEffect = ( return; } - dispatch({ type: Action.FetchingMoreEntries }); + dispatchIfMounted({ type: Action.FetchingMoreEntries }); try { const commonFetchArgs: LogEntriesBaseRequest = { @@ -232,14 +240,14 @@ const useFetchEntriesEffect = ( const { data: payload } = await fetchLogEntries(fetchArgs, services.http.fetch); - dispatch({ + dispatchIfMounted({ type: getEntriesBefore ? Action.ReceiveEntriesBefore : Action.ReceiveEntriesAfter, payload, }); return payload.bottomCursor; } catch (e) { - dispatch({ type: Action.ErrorOnMoreEntries }); + dispatchIfMounted({ type: Action.ErrorOnMoreEntries }); } }; @@ -322,7 +330,7 @@ const useFetchEntriesEffect = ( after: props.endTimestamp > prevParams.endTimestamp, }; - dispatch({ type: Action.ExpandRange, payload: shouldExpand }); + dispatchIfMounted({ type: Action.ExpandRange, payload: shouldExpand }); }; const expandRangeEffectDependencies = [ diff --git a/x-pack/plugins/infra/public/utils/use_tracked_promise.ts b/x-pack/plugins/infra/public/utils/use_tracked_promise.ts index 9951b62fa64a3..42518127f68bf 100644 --- a/x-pack/plugins/infra/public/utils/use_tracked_promise.ts +++ b/x-pack/plugins/infra/public/utils/use_tracked_promise.ts @@ -6,13 +6,15 @@ /* eslint-disable max-classes-per-file */ -import { DependencyList, useEffect, useMemo, useRef, useState } from 'react'; +import { DependencyList, useEffect, useMemo, useRef, useState, useCallback } from 'react'; +import { useMountedState } from 'react-use'; interface UseTrackedPromiseArgs { createPromise: (...args: Arguments) => Promise; onResolve?: (result: Result) => void; onReject?: (value: unknown) => void; cancelPreviousOn?: 'creation' | 'settlement' | 'resolution' | 'rejection' | 'never'; + triggerOrThrow?: 'always' | 'whenMounted'; } /** @@ -64,6 +66,16 @@ interface UseTrackedPromiseArgs { * The last argument is a normal React hook dependency list that indicates * under which conditions a new reference to the configuration object should be * used. + * + * The `onResolve`, `onReject` and possible uncatched errors are only triggered + * if the underlying component is mounted. To ensure they always trigger (i.e. + * if the promise is called in a `useLayoutEffect`) use the `triggerOrThrow` + * attribute: + * + * 'whenMounted': (default) they are called only if the component is mounted. + * + * 'always': they always call. The consumer is then responsible of ensuring no + * side effects happen if the underlying component is not mounted. */ export const useTrackedPromise = ( { @@ -71,9 +83,20 @@ export const useTrackedPromise = ( onResolve = noOp, onReject = noOp, cancelPreviousOn = 'never', + triggerOrThrow = 'whenMounted', }: UseTrackedPromiseArgs, dependencies: DependencyList ) => { + const isComponentMounted = useMountedState(); + const shouldTriggerOrThrow = useCallback(() => { + switch (triggerOrThrow) { + case 'always': + return true; + case 'whenMounted': + return isComponentMounted(); + } + }, [isComponentMounted, triggerOrThrow]); + /** * If a promise is currently pending, this holds a reference to it and its * cancellation function. @@ -144,7 +167,7 @@ export const useTrackedPromise = ( (pendingPromise) => pendingPromise.promise !== newPendingPromise.promise ); - if (onResolve) { + if (onResolve && shouldTriggerOrThrow()) { onResolve(value); } @@ -173,11 +196,13 @@ export const useTrackedPromise = ( (pendingPromise) => pendingPromise.promise !== newPendingPromise.promise ); - if (onReject) { - onReject(value); - } + if (shouldTriggerOrThrow()) { + if (onReject) { + onReject(value); + } - throw value; + throw value; + } } ), }; diff --git a/x-pack/plugins/ingest_manager/common/services/package_to_package_policy.test.ts b/x-pack/plugins/ingest_manager/common/services/package_to_package_policy.test.ts index 91396bce359b0..e81207300a5f3 100644 --- a/x-pack/plugins/ingest_manager/common/services/package_to_package_policy.test.ts +++ b/x-pack/plugins/ingest_manager/common/services/package_to_package_policy.test.ts @@ -84,16 +84,14 @@ describe('Ingest Manager - packageToPackagePolicy', () => { { type: 'foo', enabled: true, - streams: [ - { id: 'foo-foo', enabled: true, data_stream: { dataset: 'foo', type: 'logs' } }, - ], + streams: [{ enabled: true, data_stream: { dataset: 'foo', type: 'logs' } }], }, { type: 'bar', enabled: true, streams: [ - { id: 'bar-bar', enabled: true, data_stream: { dataset: 'bar', type: 'logs' } }, - { id: 'bar-bar2', enabled: true, data_stream: { dataset: 'bar2', type: 'logs' } }, + { enabled: true, data_stream: { dataset: 'bar', type: 'logs' } }, + { enabled: true, data_stream: { dataset: 'bar2', type: 'logs' } }, ], }, ]); @@ -142,7 +140,6 @@ describe('Ingest Manager - packageToPackagePolicy', () => { enabled: true, streams: [ { - id: 'foo-foo', enabled: true, data_stream: { dataset: 'foo', type: 'logs' }, vars: { 'var-name': { value: 'foo-var-value' } }, @@ -154,13 +151,11 @@ describe('Ingest Manager - packageToPackagePolicy', () => { enabled: true, streams: [ { - id: 'bar-bar', enabled: true, data_stream: { dataset: 'bar', type: 'logs' }, vars: { 'var-name': { type: 'text', value: 'bar-var-value' } }, }, { - id: 'bar-bar2', enabled: true, data_stream: { dataset: 'bar2', type: 'logs' }, vars: { 'var-name': { type: 'yaml', value: 'bar2-var-value' } }, @@ -258,7 +253,6 @@ describe('Ingest Manager - packageToPackagePolicy', () => { }, streams: [ { - id: 'foo-foo', enabled: true, data_stream: { dataset: 'foo', type: 'logs' }, vars: { @@ -276,7 +270,6 @@ describe('Ingest Manager - packageToPackagePolicy', () => { }, streams: [ { - id: 'bar-bar', enabled: true, data_stream: { dataset: 'bar', type: 'logs' }, vars: { @@ -284,7 +277,6 @@ describe('Ingest Manager - packageToPackagePolicy', () => { }, }, { - id: 'bar-bar2', enabled: true, data_stream: { dataset: 'bar2', type: 'logs' }, vars: { @@ -298,7 +290,6 @@ describe('Ingest Manager - packageToPackagePolicy', () => { enabled: false, streams: [ { - id: 'with-disabled-streams-disabled', enabled: false, data_stream: { dataset: 'disabled', type: 'logs' }, vars: { @@ -306,7 +297,6 @@ describe('Ingest Manager - packageToPackagePolicy', () => { }, }, { - id: 'with-disabled-streams-disabled2', enabled: false, data_stream: { dataset: 'disabled2', type: 'logs' }, }, diff --git a/x-pack/plugins/ingest_manager/common/services/package_to_package_policy.ts b/x-pack/plugins/ingest_manager/common/services/package_to_package_policy.ts index 822747916ebc5..cbdfa25ed7f7e 100644 --- a/x-pack/plugins/ingest_manager/common/services/package_to_package_policy.ts +++ b/x-pack/plugins/ingest_manager/common/services/package_to_package_policy.ts @@ -8,11 +8,10 @@ import { RegistryPolicyTemplate, RegistryVarsEntry, RegistryStream, - PackagePolicy, PackagePolicyConfigRecord, PackagePolicyConfigRecordEntry, - PackagePolicyInput, - PackagePolicyInputStream, + NewPackagePolicyInput, + NewPackagePolicyInputStream, NewPackagePolicy, } from '../types'; @@ -42,8 +41,10 @@ const getStreamsForInputType = ( /* * This service creates a package policy inputs definition from defaults provided in package info */ -export const packageToPackagePolicyInputs = (packageInfo: PackageInfo): PackagePolicy['inputs'] => { - const inputs: PackagePolicy['inputs'] = []; +export const packageToPackagePolicyInputs = ( + packageInfo: PackageInfo +): NewPackagePolicy['inputs'] => { + const inputs: NewPackagePolicy['inputs'] = []; // Assume package will only ever ship one package policy template for now const packagePolicyTemplate: RegistryPolicyTemplate | null = @@ -71,12 +72,11 @@ export const packageToPackagePolicyInputs = (packageInfo: PackageInfo): PackageP }; // Map each package input stream into package policy input stream - const streams: PackagePolicyInputStream[] = getStreamsForInputType( + const streams: NewPackagePolicyInputStream[] = getStreamsForInputType( packageInput.type, packageInfo ).map((packageStream) => { - const stream: PackagePolicyInputStream = { - id: `${packageInput.type}-${packageStream.data_stream.dataset}`, + const stream: NewPackagePolicyInputStream = { enabled: packageStream.enabled === false ? false : true, data_stream: packageStream.data_stream, }; @@ -86,7 +86,7 @@ export const packageToPackagePolicyInputs = (packageInfo: PackageInfo): PackageP return stream; }); - const input: PackagePolicyInput = { + const input: NewPackagePolicyInput = { type: packageInput.type, enabled: streams.length ? !!streams.find((stream) => stream.enabled) : true, streams, diff --git a/x-pack/plugins/ingest_manager/common/types/models/package_policy.ts b/x-pack/plugins/ingest_manager/common/types/models/package_policy.ts index 724dbae5dac85..ae16899a4b6f9 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/package_policy.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/package_policy.ts @@ -18,7 +18,6 @@ export interface PackagePolicyConfigRecordEntry { export type PackagePolicyConfigRecord = Record; export interface NewPackagePolicyInputStream { - id: string; enabled: boolean; data_stream: { dataset: string; @@ -29,6 +28,7 @@ export interface NewPackagePolicyInputStream { } export interface PackagePolicyInputStream extends NewPackagePolicyInputStream { + id: string; compiled_stream?: any; } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/components/package_policy_input_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/components/package_policy_input_config.tsx index 175bfb1469902..177354dad14dc 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/components/package_policy_input_config.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/components/package_policy_input_config.tsx @@ -14,7 +14,7 @@ import { EuiSpacer, EuiButtonEmpty, } from '@elastic/eui'; -import { PackagePolicyInput, RegistryVarsEntry } from '../../../../types'; +import { NewPackagePolicyInput, RegistryVarsEntry } from '../../../../types'; import { isAdvancedVar, PackagePolicyConfigValidationResults, @@ -28,8 +28,8 @@ const FlexItemWithMaxWidth = styled(EuiFlexItem)` export const PackagePolicyInputConfig: React.FunctionComponent<{ packageInputVars?: RegistryVarsEntry[]; - packagePolicyInput: PackagePolicyInput; - updatePackagePolicyInput: (updatedInput: Partial) => void; + packagePolicyInput: NewPackagePolicyInput; + updatePackagePolicyInput: (updatedInput: Partial) => void; inputVarsValidationResults: PackagePolicyConfigValidationResults; forceShowErrors?: boolean; }> = memo( diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/components/package_policy_input_panel.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/components/package_policy_input_panel.tsx index 1e43cc0d5938e..79ff0cc29850c 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/components/package_policy_input_panel.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/components/package_policy_input_panel.tsx @@ -17,7 +17,7 @@ import { EuiSpacer, } from '@elastic/eui'; import { - PackagePolicyInput, + NewPackagePolicyInput, PackagePolicyInputStream, RegistryInput, RegistryStream, @@ -40,7 +40,7 @@ const ShortenedHorizontalRule = styled(EuiHorizontalRule)` const shouldShowStreamsByDefault = ( packageInput: RegistryInput, packageInputStreams: Array, - packagePolicyInput: PackagePolicyInput + packagePolicyInput: NewPackagePolicyInput ): boolean => { return ( packagePolicyInput.enabled && @@ -63,8 +63,8 @@ const shouldShowStreamsByDefault = ( export const PackagePolicyInputPanel: React.FunctionComponent<{ packageInput: RegistryInput; packageInputStreams: Array; - packagePolicyInput: PackagePolicyInput; - updatePackagePolicyInput: (updatedInput: Partial) => void; + packagePolicyInput: NewPackagePolicyInput; + updatePackagePolicyInput: (updatedInput: Partial) => void; inputValidationResults: PackagePolicyInputValidationResults; forceShowErrors?: boolean; }> = memo( @@ -210,7 +210,7 @@ export const PackagePolicyInputPanel: React.FunctionComponent<{ ...updatedStream, }; - const updatedInput: Partial = { + const updatedInput: Partial = { streams: newStreams, }; @@ -227,7 +227,7 @@ export const PackagePolicyInputPanel: React.FunctionComponent<{ updatePackagePolicyInput(updatedInput); }} inputStreamValidationResults={ - inputValidationResults.streams![packagePolicyInputStream!.id] + inputValidationResults.streams![packagePolicyInputStream!.data_stream!.dataset] } forceShowErrors={forceShowErrors} /> diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/components/package_policy_input_stream.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/components/package_policy_input_stream.tsx index 3d33edd468151..963d0da50ce7f 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/components/package_policy_input_stream.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/components/package_policy_input_stream.tsx @@ -16,7 +16,7 @@ import { EuiSpacer, EuiButtonEmpty, } from '@elastic/eui'; -import { PackagePolicyInputStream, RegistryStream, RegistryVarsEntry } from '../../../../types'; +import { NewPackagePolicyInputStream, RegistryStream, RegistryVarsEntry } from '../../../../types'; import { isAdvancedVar, PackagePolicyConfigValidationResults, @@ -30,8 +30,8 @@ const FlexItemWithMaxWidth = styled(EuiFlexItem)` export const PackagePolicyInputStreamConfig: React.FunctionComponent<{ packageInputStream: RegistryStream; - packagePolicyInputStream: PackagePolicyInputStream; - updatePackagePolicyInputStream: (updatedStream: Partial) => void; + packagePolicyInputStream: NewPackagePolicyInputStream; + updatePackagePolicyInputStream: (updatedStream: Partial) => void; inputStreamValidationResults: PackagePolicyConfigValidationResults; forceShowErrors?: boolean; }> = memo( diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/services/validate_package_policy.test..ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/services/validate_package_policy.test.ts similarity index 91% rename from x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/services/validate_package_policy.test..ts rename to x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/services/validate_package_policy.test.ts index 9022e312ece79..8d46fed1ff14e 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/services/validate_package_policy.test..ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/services/validate_package_policy.test.ts @@ -154,7 +154,6 @@ describe('Ingest Manager - validatePackagePolicy()', () => { }, streams: [ { - id: 'foo-foo', data_stream: { dataset: 'foo', type: 'logs' }, enabled: true, vars: { 'var-name': { value: 'test_yaml: value', type: 'yaml' } }, @@ -170,13 +169,11 @@ describe('Ingest Manager - validatePackagePolicy()', () => { }, streams: [ { - id: 'bar-bar', data_stream: { dataset: 'bar', type: 'logs' }, enabled: true, vars: { 'var-name': { value: 'test_yaml: value', type: 'yaml' } }, }, { - id: 'bar-bar2', data_stream: { dataset: 'bar2', type: 'logs' }, enabled: true, vars: { 'var-name': { value: undefined, type: 'text' } }, @@ -193,13 +190,11 @@ describe('Ingest Manager - validatePackagePolicy()', () => { enabled: true, streams: [ { - id: 'with-disabled-streams-disabled', data_stream: { dataset: 'disabled', type: 'logs' }, enabled: false, vars: { 'var-name': { value: undefined, type: 'text' } }, }, { - id: 'with-disabled-streams-disabled-without-vars', data_stream: { dataset: 'disabled2', type: 'logs' }, enabled: false, }, @@ -213,8 +208,7 @@ describe('Ingest Manager - validatePackagePolicy()', () => { }, streams: [ { - id: 'with-no-stream-vars-bar', - data_stream: { dataset: 'bar', type: 'logs' }, + data_stream: { dataset: 'with-no-stream-vars-bar', type: 'logs' }, enabled: true, }, ], @@ -236,7 +230,6 @@ describe('Ingest Manager - validatePackagePolicy()', () => { }, streams: [ { - id: 'foo-foo', data_stream: { dataset: 'foo', type: 'logs' }, enabled: true, vars: { 'var-name': { value: 'invalidyaml: test\n foo bar:', type: 'yaml' } }, @@ -252,13 +245,11 @@ describe('Ingest Manager - validatePackagePolicy()', () => { }, streams: [ { - id: 'bar-bar', data_stream: { dataset: 'bar', type: 'logs' }, enabled: true, vars: { 'var-name': { value: ' \n\n', type: 'yaml' } }, }, { - id: 'bar-bar2', data_stream: { dataset: 'bar2', type: 'logs' }, enabled: true, vars: { 'var-name': { value: undefined, type: 'text' } }, @@ -275,7 +266,6 @@ describe('Ingest Manager - validatePackagePolicy()', () => { enabled: true, streams: [ { - id: 'with-disabled-streams-disabled', data_stream: { dataset: 'disabled', type: 'logs' }, enabled: false, vars: { @@ -286,7 +276,6 @@ describe('Ingest Manager - validatePackagePolicy()', () => { }, }, { - id: 'with-disabled-streams-disabled-without-vars', data_stream: { dataset: 'disabled2', type: 'logs' }, enabled: false, }, @@ -300,8 +289,7 @@ describe('Ingest Manager - validatePackagePolicy()', () => { }, streams: [ { - id: 'with-no-stream-vars-bar', - data_stream: { dataset: 'bar', type: 'logs' }, + data_stream: { dataset: 'with-no-stream-vars-bar', type: 'logs' }, enabled: true, }, ], @@ -320,21 +308,21 @@ describe('Ingest Manager - validatePackagePolicy()', () => { 'foo-input2-var-name': null, 'foo-input3-var-name': null, }, - streams: { 'foo-foo': { vars: { 'var-name': null } } }, + streams: { foo: { vars: { 'var-name': null } } }, }, bar: { vars: { 'bar-input-var-name': null, 'bar-input2-var-name': null }, streams: { - 'bar-bar': { vars: { 'var-name': null } }, - 'bar-bar2': { vars: { 'var-name': null } }, + bar: { vars: { 'var-name': null } }, + bar2: { vars: { 'var-name': null } }, }, }, 'with-disabled-streams': { streams: { - 'with-disabled-streams-disabled': { + disabled: { vars: { 'var-name': null }, }, - 'with-disabled-streams-disabled-without-vars': {}, + disabled2: {}, }, }, 'with-no-stream-vars': { @@ -364,7 +352,7 @@ describe('Ingest Manager - validatePackagePolicy()', () => { 'foo-input2-var-name': ['foo-input2-var-name is required'], 'foo-input3-var-name': ['foo-input3-var-name is required'], }, - streams: { 'foo-foo': { vars: { 'var-name': ['Invalid YAML format'] } } }, + streams: { foo: { vars: { 'var-name': ['Invalid YAML format'] } } }, }, bar: { vars: { @@ -372,14 +360,14 @@ describe('Ingest Manager - validatePackagePolicy()', () => { 'bar-input2-var-name': ['bar-input2-var-name is required'], }, streams: { - 'bar-bar': { vars: { 'var-name': ['var-name is required'] } }, - 'bar-bar2': { vars: { 'var-name': null } }, + bar: { vars: { 'var-name': ['var-name is required'] } }, + bar2: { vars: { 'var-name': null } }, }, }, 'with-disabled-streams': { streams: { - 'with-disabled-streams-disabled': { vars: { 'var-name': null } }, - 'with-disabled-streams-disabled-without-vars': {}, + disabled: { vars: { 'var-name': null } }, + disabled2: {}, }, }, 'with-no-stream-vars': { @@ -427,7 +415,7 @@ describe('Ingest Manager - validatePackagePolicy()', () => { 'foo-input2-var-name': ['foo-input2-var-name is required'], 'foo-input3-var-name': ['foo-input3-var-name is required'], }, - streams: { 'foo-foo': { vars: { 'var-name': null } } }, + streams: { foo: { vars: { 'var-name': null } } }, }, bar: { vars: { @@ -435,16 +423,16 @@ describe('Ingest Manager - validatePackagePolicy()', () => { 'bar-input2-var-name': ['bar-input2-var-name is required'], }, streams: { - 'bar-bar': { vars: { 'var-name': null } }, - 'bar-bar2': { vars: { 'var-name': null } }, + bar: { vars: { 'var-name': null } }, + bar2: { vars: { 'var-name': null } }, }, }, 'with-disabled-streams': { streams: { - 'with-disabled-streams-disabled': { + disabled: { vars: { 'var-name': null }, }, - 'with-disabled-streams-disabled-without-vars': {}, + disabled2: {}, }, }, 'with-no-stream-vars': { diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/services/validate_package_policy.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/services/validate_package_policy.ts index 9ce73c0690ccb..1126cd7e58e18 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/services/validate_package_policy.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/services/validate_package_policy.ts @@ -151,7 +151,7 @@ export const validatePackagePolicy = ( ); } - inputValidationResults.streams![stream.id] = streamValidationResults; + inputValidationResults.streams![stream.data_stream.dataset] = streamValidationResults; }); } else { delete inputValidationResults.streams; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/step_configure_package.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/step_configure_package.tsx index d3d5e60c34e58..b335ff439684b 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/step_configure_package.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/step_configure_package.tsx @@ -5,7 +5,12 @@ */ import React from 'react'; import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { PackageInfo, RegistryStream, NewPackagePolicy, PackagePolicyInput } from '../../../types'; +import { + PackageInfo, + RegistryStream, + NewPackagePolicy, + NewPackagePolicyInput, +} from '../../../types'; import { Loading } from '../../../components'; import { PackagePolicyValidationResults } from './services'; import { PackagePolicyInputPanel, CustomPackagePolicy } from './components'; @@ -71,7 +76,7 @@ export const StepConfigurePackagePolicy: React.FunctionComponent<{ packageInput={packageInput} packageInputStreams={packageInputStreams} packagePolicyInput={packagePolicyInput} - updatePackagePolicyInput={(updatedInput: Partial) => { + updatePackagePolicyInput={(updatedInput: Partial) => { const indexOfUpdatedInput = packagePolicy.inputs.findIndex( (input) => input.type === packageInput.type ); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts index 386ffa5649cc2..1cf8077aeda40 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts @@ -17,7 +17,9 @@ export { NewPackagePolicy, UpdatePackagePolicy, PackagePolicyInput, + NewPackagePolicyInput, PackagePolicyInputStream, + NewPackagePolicyInputStream, PackagePolicyConfigRecord, PackagePolicyConfigRecordEntry, Output, diff --git a/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts index 198a54ca84125..1d221b8b1eead 100644 --- a/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts @@ -35,8 +35,7 @@ import { getPackageInfo, handleInstallPackageFailure, isBulkInstallError, - installPackageFromRegistry, - installPackageByUpload, + installPackage, removeInstallation, getLimitedPackages, getInstallationObject, @@ -149,7 +148,8 @@ export const installPackageFromRegistryHandler: RequestHandler< const { pkgName, pkgVersion } = splitPkgKey(pkgkey); const installedPkg = await getInstallationObject({ savedObjectsClient, pkgName }); try { - const res = await installPackageFromRegistry({ + const res = await installPackage({ + installSource: 'registry', savedObjectsClient, pkgkey, callCluster, @@ -224,7 +224,8 @@ export const installPackageByUploadHandler: RequestHandler< const contentType = request.headers['content-type'] as string; // from types it could also be string[] or undefined but this is checked later const archiveBuffer = Buffer.from(request.body); try { - const res = await installPackageByUpload({ + const res = await installPackage({ + installSource: 'upload', savedObjectsClient, callCluster, archiveBuffer, diff --git a/x-pack/plugins/ingest_manager/server/routes/package_policy/handlers.test.ts b/x-pack/plugins/ingest_manager/server/routes/package_policy/handlers.test.ts index 44c2ccda3bd2a..f47b8499a1b69 100644 --- a/x-pack/plugins/ingest_manager/server/routes/package_policy/handlers.test.ts +++ b/x-pack/plugins/ingest_manager/server/routes/package_policy/handlers.test.ts @@ -28,6 +28,13 @@ jest.mock('../../services/package_policy', (): { create: jest.fn((soClient, callCluster, newData) => Promise.resolve({ ...newData, + inputs: newData.inputs.map((input) => ({ + ...input, + streams: input.streams.map((stream) => ({ + id: stream.data_stream.dataset, + ...stream, + })), + })), id: '1', revision: 1, updated_at: new Date().toISOString(), diff --git a/x-pack/plugins/ingest_manager/server/routes/package_policy/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/package_policy/handlers.ts index d9baeca4deb47..3a2b9ba7a744f 100644 --- a/x-pack/plugins/ingest_manager/server/routes/package_policy/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/package_policy/handlers.ts @@ -7,7 +7,6 @@ import { TypeOf } from '@kbn/config-schema'; import Boom from '@hapi/boom'; import { RequestHandler, SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; import { appContextService, packagePolicyService } from '../../services'; -import { getPackageInfo } from '../../services/epm/packages'; import { GetPackagePoliciesRequestSchema, GetOnePackagePolicyRequestSchema, @@ -134,21 +133,11 @@ export const updatePackagePolicyHandler: RequestHandler< const newData = { ...request.body }; const pkg = newData.package || packagePolicy.package; const inputs = newData.inputs || packagePolicy.inputs; - if (pkg && (newData.inputs || newData.package)) { - const pkgInfo = await getPackageInfo({ - savedObjectsClient: soClient, - pkgName: pkg.name, - pkgVersion: pkg.version, - }); - newData.inputs = (await packagePolicyService.assignPackageStream(pkgInfo, inputs)) as TypeOf< - typeof CreatePackagePolicyRequestSchema.body - >['inputs']; - } const updatedPackagePolicy = await packagePolicyService.update( soClient, request.params.packagePolicyId, - newData, + { ...newData, package: pkg, inputs }, { user } ); return response.ok({ diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/cache.ts b/x-pack/plugins/ingest_manager/server/services/epm/archive/cache.ts similarity index 95% rename from x-pack/plugins/ingest_manager/server/services/epm/registry/cache.ts rename to x-pack/plugins/ingest_manager/server/services/epm/archive/cache.ts index 695db9db73fa2..102324c18bd43 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/registry/cache.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/archive/cache.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { pkgToPkgKey } from './index'; +import { pkgToPkgKey } from '../registry/index'; const cache: Map = new Map(); export const cacheGet = (key: string) => cache.get(key); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/archive/index.ts b/x-pack/plugins/ingest_manager/server/services/epm/archive/index.ts index ee505b205fc84..27451ed6b5e60 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/archive/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/archive/index.ts @@ -6,10 +6,18 @@ import { ArchivePackage } from '../../../../common/types'; import { PackageInvalidArchiveError, PackageUnsupportedMediaTypeError } from '../../../errors'; -import { cacheSet, setArchiveFilelist } from '../registry/cache'; +import { + cacheSet, + cacheDelete, + getArchiveFilelist, + setArchiveFilelist, + deleteArchiveFilelist, +} from './cache'; import { ArchiveEntry, getBufferExtractor } from '../registry/extract'; import { parseAndVerifyArchive } from './validation'; +export * from './cache'; + export async function loadArchivePackage({ archiveBuffer, contentType, @@ -64,3 +72,15 @@ export async function unpackArchiveToCache( } return paths; } + +export const deletePackageCache = (name: string, version: string) => { + // get cached archive filelist + const paths = getArchiveFilelist(name, version); + + // delete cached archive filelist + deleteArchiveFilelist(name, version); + + // delete cached archive files + // this has been populated in unpackArchiveToCache() + paths?.forEach((path) => cacheDelete(path)); +}; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/archive/validation.ts b/x-pack/plugins/ingest_manager/server/services/epm/archive/validation.ts index e83340124a2d0..90941aaf80cdd 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/archive/validation.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/archive/validation.ts @@ -16,7 +16,7 @@ import { } from '../../../../common/types'; import { PackageInvalidArchiveError } from '../../../errors'; import { pkgToPkgKey } from '../registry'; -import { cacheGet } from '../registry/cache'; +import { cacheGet } from './cache'; // TODO: everything below performs verification of manifest.yml files, and hence duplicates functionality already implemented in the // package registry. At some point this should probably be replaced (or enhanced) with verification based on diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/_install_package.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/_install_package.test.ts index 5d3e8e9ce87d1..b7650d10b6b25 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/_install_package.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/_install_package.test.ts @@ -61,11 +61,7 @@ describe('_installPackage', () => { const installationPromise = _installPackage({ savedObjectsClient: soClient, callCluster, - pkgName: 'abc', - pkgVersion: '1.2.3', paths: [], - removable: false, - internal: false, packageInfo: { name: 'xyz', version: '4.5.6', diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/_install_package.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/_install_package.ts index f570984cc61aa..a83d9428b7c93 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/_install_package.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/_install_package.ts @@ -21,6 +21,7 @@ import { installPipelines, deletePreviousPipelines } from '../elasticsearch/inge import { installILMPolicy } from '../elasticsearch/ilm/install'; import { installKibanaAssets, getKibanaAssets } from '../kibana/assets/install'; import { updateCurrentWriteIndices } from '../elasticsearch/template/template'; +import { isRequiredPackage } from './index'; import { deleteKibanaSavedObjectsAssets } from './remove'; import { installTransform } from '../elasticsearch/transform/install'; import { createInstallation, saveKibanaAssetsRefs, updateVersion } from './install'; @@ -32,28 +33,22 @@ import { createInstallation, saveKibanaAssetsRefs, updateVersion } from './insta export async function _installPackage({ savedObjectsClient, callCluster, - pkgName, - pkgVersion, installedPkg, paths, - removable, - internal, packageInfo, installType, installSource, }: { savedObjectsClient: SavedObjectsClientContract; callCluster: CallESAsCurrentUser; - pkgName: string; - pkgVersion: string; installedPkg?: SavedObject; paths: string[]; - removable: boolean; - internal: boolean; packageInfo: InstallablePackage; installType: InstallType; installSource: InstallSource; }): Promise { + const { internal = false, name: pkgName, version: pkgVersion } = packageInfo; + const removable = !isRequiredPackage(pkgName); const toSaveESIndexPatterns = generateESIndexPatterns(packageInfo.data_streams); // add the package installation to the saved object. // if some installation already exists, just update install info diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.test.ts index eb43bef72db70..ab93a73a55f39 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.test.ts @@ -6,9 +6,9 @@ import { InstallablePackage } from '../../../types'; import { getAssets } from './assets'; -import { getArchiveFilelist } from '../registry/cache'; +import { getArchiveFilelist } from '../archive/cache'; -jest.mock('../registry/cache', () => { +jest.mock('../archive/cache', () => { return { getArchiveFilelist: jest.fn(), }; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.ts index 856f04c0c9b67..2e2090312c9ae 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.ts @@ -6,7 +6,7 @@ import { InstallablePackage } from '../../../types'; import * as Registry from '../registry'; -import { getArchiveFilelist } from '../registry/cache'; +import { getArchiveFilelist } from '../archive/cache'; // paths from RegistryPackage are routes to the assets on EPR // e.g. `/package/nginx/1.2.0/data_stream/access/fields/fields.yml` diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts index 2021b353f1a27..893df1733c58b 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts @@ -116,7 +116,7 @@ export async function getPackageInfo(options: { ] = await Promise.all([ getInstallationObject({ savedObjectsClient, pkgName }), Registry.fetchFindLatestPackage(pkgName), - Registry.loadRegistryPackage(pkgName, pkgVersion), + Registry.getRegistryPackage(pkgName, pkgVersion), ]); // add properties that aren't (or aren't yet) on Registry response diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts index 410a9c0b22537..a1128011d81e6 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts @@ -29,8 +29,7 @@ export { BulkInstallResponse, IBulkInstallPackageError, handleInstallPackageFailure, - installPackageFromRegistry, - installPackageByUpload, + installPackage, ensureInstalledPackage, } from './install'; export { removeInstallation } from './remove'; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts index 0496a6e9aeef1..00a5c689e906d 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts @@ -24,7 +24,6 @@ import * as Registry from '../registry'; import { getInstallation, getInstallationObject, - isRequiredPackage, bulkInstallPackages, isBulkInstallError, } from './index'; @@ -52,7 +51,7 @@ export async function installLatestPackage(options: { name: latestPackage.name, version: latestPackage.version, }); - return installPackageFromRegistry({ savedObjectsClient, pkgkey, callCluster }); + return installPackage({ installSource: 'registry', savedObjectsClient, pkgkey, callCluster }); } catch (err) { throw err; } @@ -148,7 +147,8 @@ export async function handleInstallPackageFailure({ } const prevVersion = `${pkgName}-${installedPkg.attributes.version}`; logger.error(`rolling back to ${prevVersion} after error installing ${pkgkey}`); - await installPackageFromRegistry({ + await installPackage({ + installSource: 'registry', savedObjectsClient, pkgkey: prevVersion, callCluster, @@ -186,7 +186,12 @@ export async function upgradePackage({ }); try { - const assets = await installPackageFromRegistry({ savedObjectsClient, pkgkey, callCluster }); + const assets = await installPackage({ + installSource: 'registry', + savedObjectsClient, + pkgkey, + callCluster, + }); return { name: pkgToUpgrade, newVersion: latestPkg.version, @@ -218,19 +223,19 @@ export async function upgradePackage({ } } -interface InstallPackageParams { +interface InstallRegistryPackageParams { savedObjectsClient: SavedObjectsClientContract; pkgkey: string; callCluster: CallESAsCurrentUser; force?: boolean; } -export async function installPackageFromRegistry({ +async function installPackageFromRegistry({ savedObjectsClient, pkgkey, callCluster, force = false, -}: InstallPackageParams): Promise { +}: InstallRegistryPackageParams): Promise { // TODO: change epm API to /packageName/version so we don't need to do this const { pkgName, pkgVersion } = Registry.splitPkgKey(pkgkey); // TODO: calls to getInstallationObject, Registry.fetchInfo, and Registry.fetchFindLatestPackge @@ -248,39 +253,38 @@ export async function installPackageFromRegistry({ throw new PackageOutdatedError(`${pkgkey} is out-of-date and cannot be installed or updated`); } - const { paths, registryPackageInfo } = await Registry.loadRegistryPackage(pkgName, pkgVersion); - - const removable = !isRequiredPackage(pkgName); - const { internal = false } = registryPackageInfo; - const installSource = 'registry'; + const { paths, registryPackageInfo } = await Registry.getRegistryPackage(pkgName, pkgVersion); return _installPackage({ savedObjectsClient, callCluster, - pkgName, - pkgVersion, installedPkg, paths, - removable, - internal, packageInfo: registryPackageInfo, installType, - installSource, + installSource: 'registry', }); } -export async function installPackageByUpload({ - savedObjectsClient, - callCluster, - archiveBuffer, - contentType, -}: { +interface InstallUploadedArchiveParams { savedObjectsClient: SavedObjectsClientContract; callCluster: CallESAsCurrentUser; archiveBuffer: Buffer; contentType: string; -}): Promise { +} + +export type InstallPackageParams = + | ({ installSource: Extract } & InstallRegistryPackageParams) + | ({ installSource: Extract } & InstallUploadedArchiveParams); + +async function installPackageByUpload({ + savedObjectsClient, + callCluster, + archiveBuffer, + contentType, +}: InstallUploadedArchiveParams): Promise { const { paths, archivePackageInfo } = await loadArchivePackage({ archiveBuffer, contentType }); + const installedPkg = await getInstallationObject({ savedObjectsClient, pkgName: archivePackageInfo.name, @@ -292,25 +296,45 @@ export async function installPackageByUpload({ ); } - const removable = !isRequiredPackage(archivePackageInfo.name); - const { internal = false } = archivePackageInfo; - const installSource = 'upload'; - return _installPackage({ savedObjectsClient, callCluster, - pkgName: archivePackageInfo.name, - pkgVersion: archivePackageInfo.version, installedPkg, paths, - removable, - internal, packageInfo: archivePackageInfo, installType, - installSource, + installSource: 'upload', }); } +export async function installPackage(args: InstallPackageParams) { + if (!('installSource' in args)) { + throw new Error('installSource is required'); + } + + if (args.installSource === 'registry') { + const { savedObjectsClient, pkgkey, callCluster, force } = args; + + return installPackageFromRegistry({ + savedObjectsClient, + pkgkey, + callCluster, + force, + }); + } else if (args.installSource === 'upload') { + const { savedObjectsClient, callCluster, archiveBuffer, contentType } = args; + + return installPackageByUpload({ + savedObjectsClient, + callCluster, + archiveBuffer, + contentType, + }); + } + // @ts-expect-error s/b impossibe b/c `never` by this point, but just in case + throw new Error(`Unknown installSource: ${args.installSource}`); +} + export const updateVersion = async ( savedObjectsClient: SavedObjectsClientContract, pkgName: string, @@ -421,7 +445,9 @@ export async function ensurePackagesCompletedInstall( const pkgkey = `${pkg.attributes.name}-${pkg.attributes.install_version}`; // reinstall package if (elapsedTime > MAX_TIME_COMPLETE_INSTALL) { - acc.push(installPackageFromRegistry({ savedObjectsClient, pkgkey, callCluster })); + acc.push( + installPackage({ installSource: 'registry', savedObjectsClient, pkgkey, callCluster }) + ); } return acc; }, []); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts index 5db47adc983c2..9fabbaf72474e 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts @@ -21,7 +21,8 @@ import { deletePipeline } from '../elasticsearch/ingest_pipeline/'; import { installIndexPatterns } from '../kibana/index_pattern/install'; import { deleteTransforms } from '../elasticsearch/transform/remove'; import { packagePolicyService, appContextService } from '../..'; -import { splitPkgKey, deletePackageCache } from '../registry'; +import { splitPkgKey } from '../registry'; +import { deletePackageCache } from '../archive'; export async function removeInstallation(options: { savedObjectsClient: SavedObjectsClientContract; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts index b1dd9a8c3c3f1..52a1894570b2a 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts @@ -19,14 +19,7 @@ import { RegistrySearchResult, } from '../../../types'; import { unpackArchiveToCache } from '../archive'; -import { - cacheGet, - cacheDelete, - getArchiveFilelist, - setArchiveFilelist, - deleteArchiveFilelist, -} from './cache'; -import { ArchiveEntry } from './extract'; +import { cacheGet, getArchiveFilelist, setArchiveFilelist } from '../archive'; import { fetchUrl, getResponse, getResponseStream } from './requests'; import { streamToBuffer } from './streams'; import { getRegistryUrl } from './registry_url'; @@ -132,27 +125,18 @@ export async function fetchCategories(params?: CategoriesParams): Promise true -): Promise { - const { archiveBuffer, archivePath } = await fetchArchiveBuffer(pkgName, pkgVersion); - const contentType = mime.lookup(archivePath); - if (!contentType) { - throw new Error(`Unknown compression format for '${archivePath}'. Please use .zip or .gz`); - } - const paths: string[] = await unpackArchiveToCache(archiveBuffer, contentType); - return paths; -} - -export async function loadRegistryPackage( +export async function getRegistryPackage( pkgName: string, pkgVersion: string ): Promise<{ paths: string[]; registryPackageInfo: RegistryPackage }> { let paths = getArchiveFilelist(pkgName, pkgVersion); if (!paths || paths.length === 0) { - paths = await unpackRegistryPackageToCache(pkgName, pkgVersion); + const { archiveBuffer, archivePath } = await fetchArchiveBuffer(pkgName, pkgVersion); + const contentType = mime.lookup(archivePath); + if (!contentType) { + throw new Error(`Unknown compression format for '${archivePath}'. Please use .zip or .gz`); + } + paths = await unpackArchiveToCache(archiveBuffer, contentType); setArchiveFilelist(pkgName, pkgVersion, paths); } @@ -200,7 +184,7 @@ export async function ensureCachedArchiveInfo( const paths = getArchiveFilelist(name, version); if (!paths || paths.length === 0) { if (installSource === 'registry') { - await loadRegistryPackage(name, version); + await getRegistryPackage(name, version); } else { throw new PackageCacheError( `Package ${name}-${version} not cached. If it was uploaded, try uninstalling and reinstalling manually.` @@ -247,15 +231,3 @@ export function groupPathsByService(paths: string[]): AssetsGroupedByServiceByTy // elasticsearch: assets.elasticsearch, }; } - -export const deletePackageCache = (name: string, version: string) => { - // get cached archive filelist - const paths = getArchiveFilelist(name, version); - - // delete cached archive filelist - deleteArchiveFilelist(name, version); - - // delete cached archive files - // this has been populated in unpackRegistryPackageToCache() - paths?.forEach((path) => cacheDelete(path)); -}; diff --git a/x-pack/plugins/ingest_manager/server/services/package_policy.test.ts b/x-pack/plugins/ingest_manager/server/services/package_policy.test.ts index 6064e5bae0634..6ae76c56436d5 100644 --- a/x-pack/plugins/ingest_manager/server/services/package_policy.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/package_policy.test.ts @@ -34,6 +34,12 @@ jest.mock('./epm/packages/assets', () => { }; }); +jest.mock('./epm/packages', () => { + return { + getPackageInfo: () => ({}), + }; +}); + jest.mock('./epm/registry', () => { return { fetchInfo: () => ({}), diff --git a/x-pack/plugins/ingest_manager/server/services/package_policy.ts b/x-pack/plugins/ingest_manager/server/services/package_policy.ts index dc3a4495191c9..0f78c97a6f2bd 100644 --- a/x-pack/plugins/ingest_manager/server/services/package_policy.ts +++ b/x-pack/plugins/ingest_manager/server/services/package_policy.ts @@ -4,10 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ import { SavedObjectsClientContract } from 'src/core/server'; +import uuid from 'uuid'; import { AuthenticatedUser } from '../../../security/server'; import { DeletePackagePoliciesResponse, PackagePolicyInput, + NewPackagePolicyInput, PackagePolicyInputStream, PackageInfo, ListWithKuery, @@ -58,6 +60,11 @@ class PackagePolicyService { throw new Error('There is already a package with the same name on this agent policy'); } } + // Add ids to stream + const packagePolicyId = options?.id || uuid.v4(); + let inputs: PackagePolicyInput[] = packagePolicy.inputs.map((input) => + assignStreamIdToInput(packagePolicyId, input) + ); // Make sure the associated package is installed if (packagePolicy.package?.name) { @@ -85,7 +92,7 @@ class PackagePolicyService { } } - packagePolicy.inputs = await this.assignPackageStream(pkgInfo, packagePolicy.inputs); + inputs = await this.assignPackageStream(pkgInfo, inputs); } const isoDate = new Date().toISOString(); @@ -93,13 +100,15 @@ class PackagePolicyService { SAVED_OBJECT_TYPE, { ...packagePolicy, + inputs, revision: 1, created_at: isoDate, created_by: options?.user?.username ?? 'system', updated_at: isoDate, updated_by: options?.user?.username ?? 'system', }, - options + + { ...options, id: packagePolicyId } ); // Assign it to the given agent policy @@ -124,18 +133,28 @@ class PackagePolicyService { const isoDate = new Date().toISOString(); // eslint-disable-next-line @typescript-eslint/naming-convention const { saved_objects } = await soClient.bulkCreate( - packagePolicies.map((packagePolicy) => ({ - type: SAVED_OBJECT_TYPE, - attributes: { - ...packagePolicy, - policy_id: agentPolicyId, - revision: 1, - created_at: isoDate, - created_by: options?.user?.username ?? 'system', - updated_at: isoDate, - updated_by: options?.user?.username ?? 'system', - }, - })) + packagePolicies.map((packagePolicy) => { + const packagePolicyId = uuid.v4(); + + const inputs = packagePolicy.inputs.map((input) => + assignStreamIdToInput(packagePolicyId, input) + ); + + return { + type: SAVED_OBJECT_TYPE, + id: packagePolicyId, + attributes: { + ...packagePolicy, + inputs, + policy_id: agentPolicyId, + revision: 1, + created_at: isoDate, + created_by: options?.user?.username ?? 'system', + updated_at: isoDate, + updated_by: options?.user?.username ?? 'system', + }, + }; + }) ); // Filter out invalid SOs @@ -255,11 +274,26 @@ class PackagePolicyService { } } + let inputs = await restOfPackagePolicy.inputs.map((input) => + assignStreamIdToInput(oldPackagePolicy.id, input) + ); + + if (packagePolicy.package?.name) { + const pkgInfo = await getPackageInfo({ + savedObjectsClient: soClient, + pkgName: packagePolicy.package.name, + pkgVersion: packagePolicy.package.version, + }); + + inputs = await this.assignPackageStream(pkgInfo, inputs); + } + await soClient.update( SAVED_OBJECT_TYPE, id, { ...restOfPackagePolicy, + inputs, revision: oldPackagePolicy.revision + 1, updated_at: new Date().toISOString(), updated_by: options?.user?.username ?? 'system', @@ -353,6 +387,15 @@ class PackagePolicyService { } } +function assignStreamIdToInput(packagePolicyId: string, input: NewPackagePolicyInput) { + return { + ...input, + streams: input.streams.map((stream) => { + return { ...stream, id: `${input.type}-${stream.data_stream.dataset}-${packagePolicyId}` }; + }), + }; +} + async function _assignPackageStreamToInput( registryPkgInfo: RegistryPackage, pkgInfo: PackageInfo, diff --git a/x-pack/plugins/ingest_manager/server/types/models/package_policy.ts b/x-pack/plugins/ingest_manager/server/types/models/package_policy.ts index 6673c12d51511..20d29c0aa18c9 100644 --- a/x-pack/plugins/ingest_manager/server/types/models/package_policy.ts +++ b/x-pack/plugins/ingest_manager/server/types/models/package_policy.ts @@ -54,7 +54,7 @@ const PackagePolicyBaseSchema = { ), streams: schema.arrayOf( schema.object({ - id: schema.string(), + id: schema.maybe(schema.string()), // BWC < 7.11 enabled: schema.boolean(), data_stream: schema.object({ dataset: schema.string(), type: schema.string() }), vars: schema.maybe(ConfigRecordSchema), diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index c9d99bcfb6d8d..0332f11aa78b3 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -451,6 +451,7 @@ export function LayerPanel( columnId: activeId, filterOperations: activeGroup.filterOperations, suggestedPriority: activeGroup?.suggestedPriority, + dimensionGroups: groups, setState: (newState: unknown) => { props.updateAll( datasourceId, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index f7a6f0597bf9c..b3ea14efbae80 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -601,7 +601,8 @@ describe('editor_frame', () => { setDatasourceState(updatedState); }); - expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(2); + // validation requires to calls this getConfiguration API + expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(6); expect(mockVisualization.getConfiguration).toHaveBeenLastCalledWith( expect.objectContaining({ state: updatedState, @@ -680,7 +681,8 @@ describe('editor_frame', () => { setDatasourceState({}); }); - expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(2); + // validation requires to calls this getConfiguration API + expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(6); expect(mockVisualization.getConfiguration).toHaveBeenLastCalledWith( expect.objectContaining({ frame: expect.objectContaining({ @@ -1193,7 +1195,8 @@ describe('editor_frame', () => { instance.find('[data-test-subj="lnsSuggestion"]').at(2).simulate('click'); }); - expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(1); + // validation requires to calls this getConfiguration API + expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(4); expect(mockVisualization.getConfiguration).toHaveBeenCalledWith( expect.objectContaining({ state: suggestionVisState, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts index 28ad6c531e255..647c0f3ac9cca 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts @@ -6,7 +6,13 @@ import { SavedObjectReference } from 'kibana/public'; import { Ast } from '@kbn/interpreter/common'; -import { Datasource, DatasourcePublicAPI, FramePublicAPI, Visualization } from '../../types'; +import { + Datasource, + DatasourcePublicAPI, + FramePublicAPI, + Visualization, + VisualizationDimensionGroupConfig, +} from '../../types'; import { buildExpression } from './expression_helpers'; import { Document } from '../../persistence/saved_object_store'; import { VisualizeFieldContext } from '../../../../../../src/plugins/ui_actions/public'; @@ -104,8 +110,24 @@ export const validateDatasourceAndVisualization = ( longMessage: string; }> | undefined => { + const layersGroups = + currentVisualizationState && + currentVisualization + ?.getLayerIds(currentVisualizationState) + .reduce>((memo, layerId) => { + const groups = currentVisualization?.getConfiguration({ + frame: frameAPI, + layerId, + state: currentVisualizationState, + }).groups; + if (groups) { + memo[layerId] = groups; + } + return memo; + }, {}); + const datasourceValidationErrors = currentDatasourceState - ? currentDataSource?.getErrorMessages(currentDatasourceState) + ? currentDataSource?.getErrorMessages(currentDatasourceState, layersGroups) : undefined; const visualizationValidationErrors = currentVisualizationState diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index 829bd333ce2cc..92a4dad14dd25 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -174,6 +174,7 @@ describe('IndexPatternDimensionEditorPanel', () => { } as unknown) as DataPublicPluginStart['fieldFormats'], } as unknown) as DataPublicPluginStart, core: {} as CoreSetup, + dimensionGroups: [], }; jest.clearAllMocks(); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts index bbd1d4e0ae3ab..dd696f8be357f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts @@ -146,6 +146,7 @@ describe('IndexPatternDimensionEditorPanel', () => { } as unknown) as DataPublicPluginStart['fieldFormats'], } as unknown) as DataPublicPluginStart, core: {} as CoreSetup, + dimensionGroups: [], }; jest.clearAllMocks(); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index ecca1b878e9a7..fa106e90d518a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -343,7 +343,7 @@ export function getIndexPatternDatasource({ getDatasourceSuggestionsFromCurrentState, getDatasourceSuggestionsForVisualizeField, - getErrorMessages(state) { + getErrorMessages(state, layersGroups) { if (!state) { return; } diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 3c96579fdc943..4ad849c5d441e 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -184,7 +184,10 @@ export interface Datasource { ) => Array>; getPublicAPI: (props: PublicAPIProps) => DatasourcePublicAPI; - getErrorMessages: (state: T) => Array<{ shortMessage: string; longMessage: string }> | undefined; + getErrorMessages: ( + state: T, + layersGroups?: Record + ) => Array<{ shortMessage: string; longMessage: string }> | undefined; /** * uniqueLabels of dimensions exposed for aria-labels of dragged dimensions */ @@ -242,6 +245,7 @@ export type DatasourceDimensionEditorProps = DatasourceDimensionPro setState: StateSetter; core: Pick; dateRange: DateRange; + dimensionGroups: VisualizationDimensionGroupConfig[]; }; export type DatasourceDimensionTriggerProps = DatasourceDimensionProps & { diff --git a/x-pack/plugins/maps/public/classes/layers/vector_tile_layer/vector_tile_layer.js b/x-pack/plugins/maps/public/classes/layers/vector_tile_layer/vector_tile_layer.js index 96dad0c01139e..dc3ace69e5a61 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_tile_layer/vector_tile_layer.js +++ b/x-pack/plugins/maps/public/classes/layers/vector_tile_layer/vector_tile_layer.js @@ -11,7 +11,7 @@ import { isRetina } from '../../../meta'; import { addSpriteSheetToMapFromImageData, loadSpriteSheetImageData, -} from '../../../connected_components/map/mb/utils'; //todo move this implementation +} from '../../../connected_components/mb_map/utils'; //todo move this implementation const MB_STYLE_TYPE_TO_OPACITY = { fill: ['fill-opacity'], diff --git a/x-pack/plugins/maps/public/connected_components/_index.scss b/x-pack/plugins/maps/public/connected_components/_index.scss index a952b3b545922..19c11d3fde662 100644 --- a/x-pack/plugins/maps/public/connected_components/_index.scss +++ b/x-pack/plugins/maps/public/connected_components/_index.scss @@ -2,4 +2,4 @@ @import 'layer_panel/index'; @import 'widget_overlay/index'; @import 'toolbar_overlay/index'; -@import 'map/features_tooltip/index'; +@import 'mb_map/features_tooltip/index'; diff --git a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx index 352aed4a8cc93..169875e63a536 100644 --- a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx +++ b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx @@ -13,7 +13,7 @@ import uuid from 'uuid/v4'; import { Filter } from 'src/plugins/data/public'; import { ActionExecutionContext, Action } from 'src/plugins/ui_actions/public'; // @ts-expect-error -import { MBMap } from '../map/mb'; +import { MBMap } from '../mb_map'; // @ts-expect-error import { WidgetOverlay } from '../widget_overlay'; // @ts-expect-error diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/draw_control/draw_circle.ts b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_circle.ts similarity index 100% rename from x-pack/plugins/maps/public/connected_components/map/mb/draw_control/draw_circle.ts rename to x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_circle.ts diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/draw_control/draw_control.js b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_control.js similarity index 97% rename from x-pack/plugins/maps/public/connected_components/map/mb/draw_control/draw_control.js rename to x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_control.js index 0356a8267c18a..089d4be28dff7 100644 --- a/x-pack/plugins/maps/public/connected_components/map/mb/draw_control/draw_control.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_control.js @@ -6,7 +6,7 @@ import _ from 'lodash'; import React from 'react'; -import { DRAW_TYPE } from '../../../../../common/constants'; +import { DRAW_TYPE } from '../../../../common/constants'; import MapboxDraw from '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw-unminified'; import DrawRectangle from 'mapbox-gl-draw-rectangle-mode'; import { DrawCircle } from './draw_circle'; @@ -15,7 +15,7 @@ import { createSpatialFilterWithGeometry, getBoundingBoxGeometry, roundCoordinates, -} from '../../../../../common/elasticsearch_util'; +} from '../../../../common/elasticsearch_util'; import { DrawTooltip } from './draw_tooltip'; const DRAW_RECTANGLE = 'draw_rectangle'; diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/draw_control/draw_tooltip.js b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_tooltip.js similarity index 97% rename from x-pack/plugins/maps/public/connected_components/map/mb/draw_control/draw_tooltip.js rename to x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_tooltip.js index c8bde29b94fb6..dd93b038ff8a1 100644 --- a/x-pack/plugins/maps/public/connected_components/map/mb/draw_control/draw_tooltip.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_tooltip.js @@ -8,7 +8,7 @@ import _ from 'lodash'; import React, { Component } from 'react'; import { EuiPopover, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { DRAW_TYPE } from '../../../../../common/constants'; +import { DRAW_TYPE } from '../../../../common/constants'; const noop = () => {}; diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/draw_control/index.js b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/index.js similarity index 83% rename from x-pack/plugins/maps/public/connected_components/map/mb/draw_control/index.js rename to x-pack/plugins/maps/public/connected_components/mb_map/draw_control/index.js index bc026c41fcf0a..230ad5b3f39d5 100644 --- a/x-pack/plugins/maps/public/connected_components/map/mb/draw_control/index.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/index.js @@ -6,8 +6,8 @@ import { connect } from 'react-redux'; import { DrawControl } from './draw_control'; -import { updateDrawState } from '../../../../actions'; -import { getDrawState, isDrawingFilter } from '../../../../selectors/map_selectors'; +import { updateDrawState } from '../../../actions'; +import { getDrawState, isDrawingFilter } from '../../../selectors/map_selectors'; function mapStateToProps(state = {}) { return { diff --git a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/__snapshots__/feature_properties.test.js.snap b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/feature_properties.test.js.snap similarity index 100% rename from x-pack/plugins/maps/public/connected_components/map/features_tooltip/__snapshots__/feature_properties.test.js.snap rename to x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/feature_properties.test.js.snap diff --git a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/__snapshots__/tooltip_header.test.js.snap b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/tooltip_header.test.js.snap similarity index 100% rename from x-pack/plugins/maps/public/connected_components/map/features_tooltip/__snapshots__/tooltip_header.test.js.snap rename to x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/__snapshots__/tooltip_header.test.js.snap diff --git a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/_index.scss b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/_index.scss similarity index 100% rename from x-pack/plugins/maps/public/connected_components/map/features_tooltip/_index.scss rename to x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/_index.scss diff --git a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/feature_geometry_filter_form.js b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_geometry_filter_form.js similarity index 100% rename from x-pack/plugins/maps/public/connected_components/map/features_tooltip/feature_geometry_filter_form.js rename to x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_geometry_filter_form.js diff --git a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/feature_properties.js b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.js similarity index 100% rename from x-pack/plugins/maps/public/connected_components/map/features_tooltip/feature_properties.js rename to x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.js diff --git a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/feature_properties.test.js b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.test.js similarity index 100% rename from x-pack/plugins/maps/public/connected_components/map/features_tooltip/feature_properties.test.js rename to x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.test.js diff --git a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/features_tooltip.js b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/features_tooltip.js similarity index 100% rename from x-pack/plugins/maps/public/connected_components/map/features_tooltip/features_tooltip.js rename to x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/features_tooltip.js diff --git a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/tooltip_header.js b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/tooltip_header.js similarity index 100% rename from x-pack/plugins/maps/public/connected_components/map/features_tooltip/tooltip_header.js rename to x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/tooltip_header.js diff --git a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/tooltip_header.test.js b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/tooltip_header.test.js similarity index 100% rename from x-pack/plugins/maps/public/connected_components/map/features_tooltip/tooltip_header.test.js rename to x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/tooltip_header.test.js diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/get_initial_view.ts b/x-pack/plugins/maps/public/connected_components/mb_map/get_initial_view.ts similarity index 87% rename from x-pack/plugins/maps/public/connected_components/map/mb/get_initial_view.ts rename to x-pack/plugins/maps/public/connected_components/mb_map/get_initial_view.ts index 20fb8186f9870..853819eb289a3 100644 --- a/x-pack/plugins/maps/public/connected_components/map/mb/get_initial_view.ts +++ b/x-pack/plugins/maps/public/connected_components/mb_map/get_initial_view.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { INITIAL_LOCATION } from '../../../../common/constants'; -import { Goto, MapCenterAndZoom } from '../../../../common/descriptor_types'; -import { MapSettings } from '../../../reducers/map'; +import { INITIAL_LOCATION } from '../../../common/constants'; +import { Goto, MapCenterAndZoom } from '../../../common/descriptor_types'; +import { MapSettings } from '../../reducers/map'; export async function getInitialView( goto: Goto | null, diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/image_utils.js b/x-pack/plugins/maps/public/connected_components/mb_map/image_utils.js similarity index 100% rename from x-pack/plugins/maps/public/connected_components/map/mb/image_utils.js rename to x-pack/plugins/maps/public/connected_components/mb_map/image_utils.js diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/index.js b/x-pack/plugins/maps/public/connected_components/mb_map/index.js similarity index 84% rename from x-pack/plugins/maps/public/connected_components/map/mb/index.js rename to x-pack/plugins/maps/public/connected_components/mb_map/index.js index 4b8df07bd1f39..cccd5e571d3e8 100644 --- a/x-pack/plugins/maps/public/connected_components/map/mb/index.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/index.js @@ -5,7 +5,7 @@ */ import { connect } from 'react-redux'; -import { MBMap } from './view'; +import { MBMap } from './mb_map'; import { mapExtentChanged, mapReady, @@ -14,7 +14,7 @@ import { clearMouseCoordinates, clearGoto, setMapInitError, -} from '../../../actions'; +} from '../../actions'; import { getLayerList, getMapReady, @@ -25,9 +25,9 @@ import { isViewControlHidden, getSpatialFiltersLayer, getMapSettings, -} from '../../../selectors/map_selectors'; +} from '../../selectors/map_selectors'; -import { getInspectorAdapters } from '../../../reducers/non_serializable_instances'; +import { getInspectorAdapters } from '../../reducers/non_serializable_instances'; function mapStateToProps(state = {}) { return { @@ -72,7 +72,5 @@ function mapDispatchToProps(dispatch) { }; } -const connectedMBMap = connect(mapStateToProps, mapDispatchToProps, null, { - forwardRef: true, -})(MBMap); -export { connectedMBMap as MBMap }; +const connected = connect(mapStateToProps, mapDispatchToProps)(MBMap); +export { connected as MBMap }; diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/mb.utils.test.js b/x-pack/plugins/maps/public/connected_components/mb_map/mb.utils.test.js similarity index 98% rename from x-pack/plugins/maps/public/connected_components/map/mb/mb.utils.test.js rename to x-pack/plugins/maps/public/connected_components/mb_map/mb.utils.test.js index e2050724ef684..a28cc75f6d89d 100644 --- a/x-pack/plugins/maps/public/connected_components/map/mb/mb.utils.test.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/mb.utils.test.js @@ -5,7 +5,7 @@ */ import { removeOrphanedSourcesAndLayers } from './utils'; -import { SPATIAL_FILTERS_LAYER_ID } from '../../../../common/constants'; +import { SPATIAL_FILTERS_LAYER_ID } from '../../../common/constants'; import _ from 'lodash'; class MockMbMap { diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/view.js b/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.js similarity index 97% rename from x-pack/plugins/maps/public/connected_components/map/mb/view.js rename to x-pack/plugins/maps/public/connected_components/mb_map/mb_map.js index ddc48cfc9c329..04c376a093623 100644 --- a/x-pack/plugins/maps/public/connected_components/map/mb/view.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.js @@ -6,15 +6,15 @@ import _ from 'lodash'; import React from 'react'; -import { ResizeChecker } from '../../../../../../../src/plugins/kibana_utils/public'; +import { ResizeChecker } from '../../../../../../src/plugins/kibana_utils/public'; import { removeOrphanedSourcesAndLayers, addSpritesheetToMap } from './utils'; import { syncLayerOrder } from './sort_layers'; -import { getGlyphUrl, isRetina } from '../../../meta'; +import { getGlyphUrl, isRetina } from '../../meta'; import { DECIMAL_DEGREES_PRECISION, KBN_TOO_MANY_FEATURES_IMAGE_ID, ZOOM_PRECISION, -} from '../../../../common/constants'; +} from '../../../common/constants'; import mapboxgl from 'mapbox-gl/dist/mapbox-gl-csp'; import mbWorkerUrl from '!!file-loader!mapbox-gl/dist/mapbox-gl-csp-worker'; import mbRtlPlugin from '!!file-loader!@mapbox/mapbox-gl-rtl-text/mapbox-gl-rtl-text.min.js'; @@ -23,9 +23,9 @@ import sprites1 from '@elastic/maki/dist/sprite@1.png'; import sprites2 from '@elastic/maki/dist/sprite@2.png'; import { DrawControl } from './draw_control'; import { TooltipControl } from './tooltip_control'; -import { clampToLatBounds, clampToLonBounds } from '../../../../common/elasticsearch_util'; +import { clampToLatBounds, clampToLonBounds } from '../../../common/elasticsearch_util'; import { getInitialView } from './get_initial_view'; -import { getPreserveDrawingBuffer } from '../../../kibana_services'; +import { getPreserveDrawingBuffer } from '../../kibana_services'; mapboxgl.workerUrl = mbWorkerUrl; mapboxgl.setRTLTextPlugin(mbRtlPlugin); diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/sort_layers.test.ts b/x-pack/plugins/maps/public/connected_components/mb_map/sort_layers.test.ts similarity index 98% rename from x-pack/plugins/maps/public/connected_components/map/mb/sort_layers.test.ts rename to x-pack/plugins/maps/public/connected_components/mb_map/sort_layers.test.ts index e26a1e43509c8..9e85c7b04b266 100644 --- a/x-pack/plugins/maps/public/connected_components/map/mb/sort_layers.test.ts +++ b/x-pack/plugins/maps/public/connected_components/mb_map/sort_layers.test.ts @@ -8,8 +8,8 @@ import _ from 'lodash'; import { Map as MbMap, Layer as MbLayer, Style as MbStyle } from 'mapbox-gl'; import { getIsTextLayer, syncLayerOrder } from './sort_layers'; -import { SPATIAL_FILTERS_LAYER_ID } from '../../../../common/constants'; -import { ILayer } from '../../../classes/layers/layer'; +import { SPATIAL_FILTERS_LAYER_ID } from '../../../common/constants'; +import { ILayer } from '../../classes/layers/layer'; let moveCounter = 0; diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/sort_layers.ts b/x-pack/plugins/maps/public/connected_components/mb_map/sort_layers.ts similarity index 98% rename from x-pack/plugins/maps/public/connected_components/map/mb/sort_layers.ts rename to x-pack/plugins/maps/public/connected_components/mb_map/sort_layers.ts index 0c970fe663557..dda43269e32d8 100644 --- a/x-pack/plugins/maps/public/connected_components/map/mb/sort_layers.ts +++ b/x-pack/plugins/maps/public/connected_components/mb_map/sort_layers.ts @@ -5,7 +5,7 @@ */ import { Map as MbMap, Layer as MbLayer } from 'mapbox-gl'; -import { ILayer } from '../../../classes/layers/layer'; +import { ILayer } from '../../classes/layers/layer'; // "Layer" is overloaded and can mean the following // 1) Map layer (ILayer): A single map layer consists of one to many mapbox layers. diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/__snapshots__/tooltip_control.test.js.snap b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/__snapshots__/tooltip_control.test.js.snap similarity index 100% rename from x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/__snapshots__/tooltip_control.test.js.snap rename to x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/__snapshots__/tooltip_control.test.js.snap diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/__snapshots__/tooltip_popover.test.js.snap b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/__snapshots__/tooltip_popover.test.js.snap similarity index 100% rename from x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/__snapshots__/tooltip_popover.test.js.snap rename to x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/__snapshots__/tooltip_popover.test.js.snap diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/index.js b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/index.js similarity index 94% rename from x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/index.js rename to x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/index.js index 407dcf1997aeb..7d2f2b05d6f11 100644 --- a/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/index.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/index.js @@ -11,13 +11,13 @@ import { openOnClickTooltip, closeOnHoverTooltip, openOnHoverTooltip, -} from '../../../../actions'; +} from '../../../actions'; import { getLayerList, getOpenTooltips, getHasLockedTooltips, isDrawingFilter, -} from '../../../../selectors/map_selectors'; +} from '../../../selectors/map_selectors'; function mapStateToProps(state = {}) { return { diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.js b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.js similarity index 98% rename from x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.js rename to x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.js index edfeb3c76b104..b178eef6fa5d3 100644 --- a/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.js @@ -6,9 +6,9 @@ import _ from 'lodash'; import React from 'react'; -import { FEATURE_ID_PROPERTY_NAME, LON_INDEX } from '../../../../../common/constants'; +import { FEATURE_ID_PROPERTY_NAME, LON_INDEX } from '../../../../common/constants'; import { TooltipPopover } from './tooltip_popover'; -import { EXCLUDE_TOO_MANY_FEATURES_BOX } from '../../../../classes/util/mb_filter_expressions'; +import { EXCLUDE_TOO_MANY_FEATURES_BOX } from '../../../classes/util/mb_filter_expressions'; function justifyAnchorLocation(mbLngLat, targetFeature) { let popupAnchorLocation = [mbLngLat.lng, mbLngLat.lat]; // default popup location to mouse location diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.test.js b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.test.js similarity index 100% rename from x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.test.js rename to x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.test.js diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_popover.js b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_popover.js similarity index 97% rename from x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_popover.js rename to x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_popover.js index 4cfddf0034039..ca4864f79940e 100644 --- a/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_popover.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_popover.js @@ -5,8 +5,8 @@ */ import React, { Component } from 'react'; -import { LAT_INDEX, LON_INDEX } from '../../../../../common/constants'; -import { FeaturesTooltip } from '../../features_tooltip/features_tooltip'; +import { LAT_INDEX, LON_INDEX } from '../../../../common/constants'; +import { FeaturesTooltip } from '../features_tooltip/features_tooltip'; import { EuiPopover, EuiText } from '@elastic/eui'; const noop = () => {}; diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_popover.test.js b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_popover.test.js similarity index 98% rename from x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_popover.test.js rename to x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_popover.test.js index 205ca7337277d..b15c3fce6c0b7 100644 --- a/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_popover.test.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_popover.test.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -jest.mock('../../features_tooltip/features_tooltip', () => ({ +jest.mock('../features_tooltip/features_tooltip', () => ({ FeaturesTooltip: () => { return
mockFeaturesTooltip
; }, diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/utils.js b/x-pack/plugins/maps/public/connected_components/mb_map/utils.js similarity index 100% rename from x-pack/plugins/maps/public/connected_components/map/mb/utils.js rename to x-pack/plugins/maps/public/connected_components/mb_map/utils.js diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts index 785f3ac9cd4dc..d46adff3de2a3 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts @@ -33,6 +33,7 @@ export const MAX_COLUMNS = 10; export const DEFAULT_REGRESSION_COLUMNS = 8; export const BASIC_NUMERICAL_TYPES = new Set([ + ES_FIELD_TYPES.UNSIGNED_LONG, ES_FIELD_TYPES.LONG, ES_FIELD_TYPES.INTEGER, ES_FIELD_TYPES.SHORT, diff --git a/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts b/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts index c3b1de64c3eb5..fec60f221b4fc 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts @@ -27,6 +27,7 @@ const supportedTypes: string[] = [ ES_FIELD_TYPES.INTEGER, ES_FIELD_TYPES.FLOAT, ES_FIELD_TYPES.LONG, + ES_FIELD_TYPES.UNSIGNED_LONG, ES_FIELD_TYPES.BYTE, ES_FIELD_TYPES.HALF_FLOAT, ES_FIELD_TYPES.SCALED_FLOAT, @@ -245,6 +246,7 @@ function getNumericalFields(fields: Field[]): Field[] { return fields.filter( (f) => f.type === ES_FIELD_TYPES.LONG || + f.type === ES_FIELD_TYPES.UNSIGNED_LONG || f.type === ES_FIELD_TYPES.INTEGER || f.type === ES_FIELD_TYPES.SHORT || f.type === ES_FIELD_TYPES.BYTE || diff --git a/x-pack/plugins/observability/common/annotations.ts b/x-pack/plugins/observability/common/annotations.ts index 6aea4d3d92f9b..f7ab243cf73f3 100644 --- a/x-pack/plugins/observability/common/annotations.ts +++ b/x-pack/plugins/observability/common/annotations.ts @@ -5,7 +5,24 @@ */ import * as t from 'io-ts'; -import { dateAsStringRt } from '../../apm/common/runtime_types/date_as_string_rt'; +import { either } from 'fp-ts/lib/Either'; + +/** + * Checks whether a string is a valid ISO timestamp, + * but doesn't convert it into a Date object when decoding. + * + * Copied from x-pack/plugins/apm/common/runtime_types/date_as_string_rt.ts. + */ +const dateAsStringRt = new t.Type( + 'DateAsString', + t.string.is, + (input, context) => + either.chain(t.string.validate(input, context), (str) => { + const date = new Date(str); + return isNaN(date.getTime()) ? t.failure(input, context) : t.success(str); + }), + t.identity +); export const createAnnotationRt = t.intersection([ t.type({ diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 767a2616a4c7e..8c423c663a4e8 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -39,6 +39,9 @@ export const FILTERS_GLOBAL_HEIGHT = 109; // px export const FULL_SCREEN_TOGGLED_CLASS_NAME = 'fullScreenToggled'; export const NO_ALERT_INDEX = 'no-alert-index-049FC71A-4C2C-446F-9901-37XMC5024C51'; export const ENDPOINT_METADATA_INDEX = 'metrics-endpoint.metadata-*'; +export const DEFAULT_RULE_REFRESH_INTERVAL_ON = true; +export const DEFAULT_RULE_REFRESH_INTERVAL_VALUE = 60000; // ms +export const DEFAULT_RULE_REFRESH_IDLE_VALUE = 2700000; // ms export enum SecurityPageName { detections = 'detections', @@ -74,6 +77,9 @@ export const DEFAULT_INDEX_PATTERN = [ /** This Kibana Advanced Setting enables the `Security news` feed widget */ export const ENABLE_NEWS_FEED_SETTING = 'securitySolution:enableNewsFeed'; +/** This Kibana Advanced Setting sets the auto refresh interval for the detections all rules table */ +export const DEFAULT_RULES_TABLE_REFRESH_SETTING = 'securitySolution:rulesTableRefresh'; + /** This Kibana Advanced Setting specifies the URL of the News feed widget */ export const NEWS_FEED_URL_SETTING = 'securitySolution:newsFeedUrl'; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts index 6ffbf4e4c8d4c..1b0417cf59bc2 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts @@ -48,6 +48,8 @@ import { } from '../common/schemas'; import { threat_index, + concurrent_searches, + items_per_search, threat_query, threat_filters, threat_mapping, @@ -130,6 +132,8 @@ export const addPrepackagedRulesSchema = t.intersection([ threat_query, // defaults to "undefined" if not set during decode threat_index, // defaults to "undefined" if not set during decode threat_language, // defaults "undefined" if not set during decode + concurrent_searches, // defaults to "undefined" if not set during decode + items_per_search, // defaults to "undefined" if not set during decode }) ), ]); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.test.ts index a4f002b589ef5..1b6a8d6f27762 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.test.ts @@ -1702,5 +1702,23 @@ describe('create rules schema', () => { expect(getPaths(left(message.errors))).toEqual([]); expect(message.schema).toEqual(expected); }); + + test('You can set a threat query, index, mapping, filters, concurrent_searches, items_per_search with a when creating a rule', () => { + const payload: CreateRulesSchema = { + ...getCreateThreatMatchRulesSchemaMock(), + concurrent_searches: 10, + items_per_search: 10, + }; + const decoded = createRulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + const expected: CreateRulesSchemaDecoded = { + ...getCreateThreatMatchRulesSchemaDecodedMock(), + concurrent_searches: 10, + items_per_search: 10, + }; + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(expected); + }); }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.ts index d8e7614fcb840..2fe52bbe470a5 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.ts @@ -49,6 +49,8 @@ import { } from '../common/schemas'; import { threat_index, + concurrent_searches, + items_per_search, threat_query, threat_filters, threat_mapping, @@ -126,6 +128,8 @@ export const createRulesSchema = t.intersection([ threat_filters, // defaults to "undefined" if not set during decode threat_index, // defaults to "undefined" if not set during decode threat_language, // defaults "undefined" if not set during decode + concurrent_searches, // defaults "undefined" if not set during decode + items_per_search, // defaults "undefined" if not set during decode }) ), ]); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.test.ts index 75ad92578318c..a78b41cd0da18 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.test.ts @@ -125,4 +125,36 @@ describe('create_rules_type_dependents', () => { const errors = createRuleValidateTypeDependents(schema); expect(errors).toEqual([]); }); + + test('validates that both "items_per_search" and "concurrent_searches" works when together', () => { + const schema: CreateRulesSchema = { + ...getCreateThreatMatchRulesSchemaMock(), + concurrent_searches: 10, + items_per_search: 10, + }; + const errors = createRuleValidateTypeDependents(schema); + expect(errors).toEqual([]); + }); + + test('does NOT validate when only "items_per_search" is present', () => { + const schema: CreateRulesSchema = { + ...getCreateThreatMatchRulesSchemaMock(), + items_per_search: 10, + }; + const errors = createRuleValidateTypeDependents(schema); + expect(errors).toEqual([ + 'when "items_per_search" exists, "concurrent_searches" must also exist', + ]); + }); + + test('does NOT validate when only "concurrent_searches" is present', () => { + const schema: CreateRulesSchema = { + ...getCreateThreatMatchRulesSchemaMock(), + concurrent_searches: 10, + }; + const errors = createRuleValidateTypeDependents(schema); + expect(errors).toEqual([ + 'when "concurrent_searches" exists, "items_per_search" must also exist', + ]); + }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.ts index c2a41005ebf4d..c93b0f0b14f6a 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.ts @@ -110,17 +110,23 @@ export const validateThreshold = (rule: CreateRulesSchema): string[] => { export const validateThreatMapping = (rule: CreateRulesSchema): string[] => { let errors: string[] = []; if (isThreatMatchRule(rule.type)) { - if (!rule.threat_mapping) { + if (rule.threat_mapping == null) { errors = ['when "type" is "threat_match", "threat_mapping" is required', ...errors]; } else if (rule.threat_mapping.length === 0) { errors = ['threat_mapping" must have at least one element', ...errors]; } - if (!rule.threat_query) { + if (rule.threat_query == null) { errors = ['when "type" is "threat_match", "threat_query" is required', ...errors]; } - if (!rule.threat_index) { + if (rule.threat_index == null) { errors = ['when "type" is "threat_match", "threat_index" is required', ...errors]; } + if (rule.concurrent_searches == null && rule.items_per_search != null) { + errors = ['when "items_per_search" exists, "concurrent_searches" must also exist', ...errors]; + } + if (rule.concurrent_searches != null && rule.items_per_search == null) { + errors = ['when "concurrent_searches" exists, "items_per_search" must also exist', ...errors]; + } } return errors; }; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts index 852394b74767b..4f28c46923865 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts @@ -55,6 +55,8 @@ import { } from '../common/schemas'; import { threat_index, + items_per_search, + concurrent_searches, threat_query, threat_filters, threat_mapping, @@ -149,6 +151,8 @@ export const importRulesSchema = t.intersection([ threat_query, // defaults to "undefined" if not set during decode threat_index, // defaults to "undefined" if not set during decode threat_language, // defaults "undefined" if not set during decode + concurrent_searches, // defaults to "undefined" if not set during decode + items_per_search, // defaults to "undefined" if not set during decode }) ), ]); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts index f4dce5c7ac05f..45fcfbaa3c76a 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts @@ -50,6 +50,8 @@ import { } from '../common/schemas'; import { threat_index, + concurrent_searches, + items_per_search, threat_query, threat_filters, threat_mapping, @@ -109,6 +111,8 @@ export const patchRulesSchema = t.exact( threat_filters, threat_mapping, threat_language, + concurrent_searches, + items_per_search, }) ); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.ts index b0cd8b1c53688..5d759fc12cd52 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.ts @@ -51,6 +51,8 @@ import { } from '../common/schemas'; import { threat_index, + concurrent_searches, + items_per_search, threat_query, threat_filters, threat_mapping, @@ -134,6 +136,8 @@ export const updateRulesSchema = t.intersection([ threat_filters, // defaults to "undefined" if not set during decode threat_index, // defaults to "undefined" if not set during decode threat_language, // defaults "undefined" if not set during decode + concurrent_searches, // defaults to "undefined" if not set during decode + items_per_search, // defaults to "undefined" if not set during decode }) ), ]); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.test.ts index 82675768a11b7..3508526e182d7 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.test.ts @@ -762,9 +762,9 @@ describe('rules_schema', () => { expect(fields).toEqual(expected); }); - test('should return 5 fields for a rule of type "threat_match"', () => { + test('should return 8 fields for a rule of type "threat_match"', () => { const fields = addThreatMatchFields({ type: 'threat_match' }); - expect(fields.length).toEqual(6); + expect(fields.length).toEqual(8); }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts index e85beddf0e51e..0f7d04763a36f 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts @@ -63,6 +63,8 @@ import { } from '../common/schemas'; import { threat_index, + concurrent_searches, + items_per_search, threat_query, threat_filters, threat_mapping, @@ -144,6 +146,8 @@ export const dependentRulesSchema = t.partial({ threat_filters, threat_index, threat_query, + concurrent_searches, + items_per_search, threat_mapping, threat_language, }); @@ -282,6 +286,12 @@ export const addThreatMatchFields = (typeAndTimelineOnly: TypeAndTimelineOnly): t.exact(t.partial({ threat_language: dependentRulesSchema.props.threat_language })), t.exact(t.partial({ threat_filters: dependentRulesSchema.props.threat_filters })), t.exact(t.partial({ saved_id: dependentRulesSchema.props.saved_id })), + t.exact(t.partial({ concurrent_searches: dependentRulesSchema.props.concurrent_searches })), + t.exact( + t.partial({ + items_per_search: dependentRulesSchema.props.items_per_search, + }) + ), ]; } else { return []; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/threat_mapping.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/threat_mapping.test.ts index 63d593ea84e67..d8f61e4309b17 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/threat_mapping.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/threat_mapping.test.ts @@ -5,6 +5,8 @@ */ import { + concurrent_searches, + items_per_search, ThreatMapping, threatMappingEntries, ThreatMappingEntries, @@ -33,7 +35,7 @@ describe('threat_mapping', () => { expect(message.schema).toEqual(payload); }); - test('it should NOT validate an extra entry item', () => { + test('it should fail validation with an extra entry item', () => { const payload: ThreatMappingEntries & Array<{ extra: string }> = [ { field: 'field.one', @@ -50,7 +52,7 @@ describe('threat_mapping', () => { expect(message.schema).toEqual({}); }); - test('it should NOT validate a non string', () => { + test('it should fail validation with a non string', () => { const payload = ([ { field: 5, @@ -66,7 +68,7 @@ describe('threat_mapping', () => { expect(message.schema).toEqual({}); }); - test('it should NOT validate a wrong type', () => { + test('it should fail validation with a wrong type', () => { const payload = ([ { field: 'field.one', @@ -107,7 +109,7 @@ describe('threat_mapping', () => { }); }); - test('it should NOT validate an extra key', () => { + test('it should fail validate with an extra key', () => { const payload: ThreatMapping & Array<{ extra: string }> = [ { entries: [ @@ -129,7 +131,7 @@ describe('threat_mapping', () => { expect(message.schema).toEqual({}); }); - test('it should NOT validate an extra inner entry', () => { + test('it should fail validate with an extra inner entry', () => { const payload: ThreatMapping & Array<{ entries: Array<{ extra: string }> }> = [ { entries: [ @@ -151,7 +153,7 @@ describe('threat_mapping', () => { expect(message.schema).toEqual({}); }); - test('it should NOT validate an extra inner entry with the wrong data type', () => { + test('it should fail validate with an extra inner entry with the wrong data type', () => { const payload = ([ { entries: [ @@ -173,4 +175,48 @@ describe('threat_mapping', () => { ]); expect(message.schema).toEqual({}); }); + + test('it should fail validation when concurrent_searches is < 0', () => { + const payload = -1; + const decoded = concurrent_searches.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "-1" supplied to "PositiveIntegerGreaterThanZero"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should fail validation when concurrent_searches is 0', () => { + const payload = 0; + const decoded = concurrent_searches.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "0" supplied to "PositiveIntegerGreaterThanZero"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should fail validation when items_per_search is 0', () => { + const payload = 0; + const decoded = items_per_search.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "0" supplied to "PositiveIntegerGreaterThanZero"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should fail validation when items_per_search is < 0', () => { + const payload = -1; + const decoded = items_per_search.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "-1" supplied to "PositiveIntegerGreaterThanZero"', + ]); + expect(message.schema).toEqual({}); + }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/threat_mapping.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/threat_mapping.ts index a1be6485f596b..dec8ddd000132 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/threat_mapping.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/threat_mapping.ts @@ -9,6 +9,7 @@ import * as t from 'io-ts'; import { language } from '../common/schemas'; import { NonEmptyString } from './non_empty_string'; +import { PositiveIntegerGreaterThanZero } from './positive_integer_greater_than_zero'; export const threat_query = t.string; export type ThreatQuery = t.TypeOf; @@ -55,3 +56,13 @@ export const threat_language = t.union([language, t.undefined]); export type ThreatLanguage = t.TypeOf; export const threatLanguageOrUndefined = t.union([threat_language, t.undefined]); export type ThreatLanguageOrUndefined = t.TypeOf; + +export const concurrent_searches = PositiveIntegerGreaterThanZero; +export type ConcurrentSearches = t.TypeOf; +export const concurrentSearchesOrUndefined = t.union([concurrent_searches, t.undefined]); +export type ConcurrentSearchesOrUndefined = t.TypeOf; + +export const items_per_search = PositiveIntegerGreaterThanZero; +export type ItemsPerSearch = t.TypeOf; +export const itemsPerSearchOrUndefined = t.union([items_per_search, t.undefined]); +export type ItemsPerSearchOrUndefined = t.TypeOf; diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules.spec.ts index 3fa304ab7cf19..6a62caecfaa67 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules.spec.ts @@ -10,6 +10,7 @@ import { RULE_SWITCH, SECOND_RULE, SEVENTH_RULE, + RULE_AUTO_REFRESH_IDLE_MODAL, } from '../screens/alerts_detection_rules'; import { @@ -19,12 +20,17 @@ import { } from '../tasks/alerts'; import { activateRule, + checkAllRulesIdleModal, + checkAutoRefresh, + dismissAllRulesIdleModal, + resetAllRulesIdleModalTimeout, sortByActivatedRules, waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded, waitForRuleToBeActivated, } from '../tasks/alerts_detection_rules'; import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; +import { DEFAULT_RULE_REFRESH_INTERVAL_VALUE } from '../../common/constants'; import { DETECTIONS_URL } from '../urls/navigation'; @@ -35,6 +41,7 @@ describe('Alerts detection rules', () => { after(() => { esArchiverUnload('prebuilt_rules_loaded'); + cy.clock().invoke('restore'); }); it('Sorts by activated rules', () => { @@ -75,4 +82,34 @@ describe('Alerts detection rules', () => { }); }); }); + + it('Auto refreshes rules', () => { + cy.clock(Date.now()); + + loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); + waitForAlertsPanelToBeLoaded(); + waitForAlertsIndexToBeCreated(); + goToManageAlertsDetectionRules(); + waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded(); + + // mock 1 minute passing to make sure refresh + // is conducted + checkAutoRefresh(DEFAULT_RULE_REFRESH_INTERVAL_VALUE, 'be.visible'); + + // mock 45 minutes passing to check that idle modal shows + // and refreshing is paused + checkAllRulesIdleModal('be.visible'); + checkAutoRefresh(DEFAULT_RULE_REFRESH_INTERVAL_VALUE, 'not.be.visible'); + + // clicking on modal to continue, should resume refreshing + dismissAllRulesIdleModal(); + checkAutoRefresh(DEFAULT_RULE_REFRESH_INTERVAL_VALUE, 'be.visible'); + + // if mouse movement detected, idle modal should not + // show after 45 min + resetAllRulesIdleModalTimeout(); + cy.get(RULE_AUTO_REFRESH_IDLE_MODAL).should('not.exist'); + + cy.clock().invoke('restore'); + }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/events_viewer.spec.ts b/x-pack/plugins/security_solution/cypress/integration/events_viewer.spec.ts index 13fa9592469e4..9eb49c19c23f6 100644 --- a/x-pack/plugins/security_solution/cypress/integration/events_viewer.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/events_viewer.spec.ts @@ -142,10 +142,11 @@ describe('Events Viewer', () => { }); }); - context.skip('Events columns', () => { + context('Events columns', () => { before(() => { loginAndWaitForPage(HOSTS_URL); openEvents(); + cy.scrollTo('bottom'); waitsForEventsToBeLoaded(); }); @@ -160,9 +161,8 @@ describe('Events Viewer', () => { const expectedOrderAfterDragAndDrop = 'message@timestamphost.nameevent.moduleevent.datasetevent.actionuser.namesource.ipdestination.ip'; - cy.scrollTo('bottom'); cy.get(HEADERS_GROUP).invoke('text').should('equal', originalColumnOrder); - dragAndDropColumn({ column: 0, newPosition: 1 }); + dragAndDropColumn({ column: 0, newPosition: 0 }); cy.get(HEADERS_GROUP).invoke('text').should('equal', expectedOrderAfterDragAndDrop); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_local_storage.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_local_storage.spec.ts index 383ebe2220585..d518f9e42f21f 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_local_storage.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_local_storage.spec.ts @@ -13,7 +13,8 @@ import { TABLE_COLUMN_EVENTS_MESSAGE } from '../screens/hosts/external_events'; import { waitsForEventsToBeLoaded, openEventsViewerFieldsBrowser } from '../tasks/hosts/events'; import { removeColumn, resetFields } from '../tasks/timeline'; -describe('persistent timeline', () => { +// Failing: See https://github.com/elastic/kibana/issues/75794 +describe.skip('persistent timeline', () => { before(() => { loginAndWaitForPage(HOSTS_URL); openEvents(); diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts index 0d0ea8460edf1..5ac8cd8f6cc9f 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts @@ -10,7 +10,7 @@ export const CREATE_NEW_RULE_BTN = '[data-test-subj="create-new-rule"]'; export const COLLAPSED_ACTION_BTN = '[data-test-subj="euiCollapsedItemActionsButton"]'; -export const CUSTOM_RULES_BTN = '[data-test-subj="show-custom-rules-filter-button"]'; +export const CUSTOM_RULES_BTN = '[data-test-subj="showCustomRulesFilterButton"]'; export const DELETE_RULE_ACTION_BTN = '[data-test-subj="deleteRuleAction"]'; @@ -18,7 +18,7 @@ export const EDIT_RULE_ACTION_BTN = '[data-test-subj="editRuleAction"]'; export const DELETE_RULE_BULK_BTN = '[data-test-subj="deleteRuleBulk"]'; -export const ELASTIC_RULES_BTN = '[data-test-subj="show-elastic-rules-filter-button"]'; +export const ELASTIC_RULES_BTN = '[data-test-subj="showElasticRulesFilterButton"]'; export const EXPORT_ACTION_BTN = '[data-test-subj="exportRuleAction"]'; @@ -31,7 +31,7 @@ export const LOAD_PREBUILT_RULES_BTN = '[data-test-subj="load-prebuilt-rules"]'; export const LOADING_INITIAL_PREBUILT_RULES_TABLE = '[data-test-subj="initialLoadingPanelAllRulesTable"]'; -export const LOADING_SPINNER = '[data-test-subj="loading-spinner"]'; +export const ASYNC_LOADING_PROGRESS = '[data-test-subj="loadingRulesInfoProgress"]'; export const NEXT_BTN = '[data-test-subj="pagination-button-next"]'; @@ -64,3 +64,7 @@ export const SHOWING_RULES_TEXT = '[data-test-subj="showingRules"]'; export const SORT_RULES_BTN = '[data-test-subj="tableHeaderSortButton"]'; export const THREE_HUNDRED_ROWS = '[data-test-subj="tablePagination-300-rows"]'; + +export const RULE_AUTO_REFRESH_IDLE_MODAL = '[data-test-subj="allRulesIdleModal"]'; + +export const RULE_AUTO_REFRESH_IDLE_MODAL_CONTINUE = '[data-test-subj="allRulesIdleModal"] button'; diff --git a/x-pack/plugins/security_solution/cypress/screens/hosts/events.ts b/x-pack/plugins/security_solution/cypress/screens/hosts/events.ts index 0434de7bff88e..cf507924a753f 100644 --- a/x-pack/plugins/security_solution/cypress/screens/hosts/events.ts +++ b/x-pack/plugins/security_solution/cypress/screens/hosts/events.ts @@ -34,3 +34,6 @@ export const LOAD_MORE = '[data-test-subj="events-viewer-panel"] [data-test-subj="TimelineMoreButton"'; export const SERVER_SIDE_EVENT_COUNT = '[data-test-subj="server-side-event-count"]'; + +export const EVENTS_VIEWER_PAGINATION = + '[data-test-subj="events-viewer-panel"] [data-test-subj="timeline-pagination"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts index 1c430e12b6b73..d4602dcd16db8 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts @@ -13,7 +13,6 @@ import { DELETE_RULE_BULK_BTN, LOAD_PREBUILT_RULES_BTN, LOADING_INITIAL_PREBUILT_RULES_TABLE, - LOADING_SPINNER, PAGINATION_POPOVER_BTN, RELOAD_PREBUILT_RULES_BTN, RULE_CHECKBOX, @@ -26,6 +25,9 @@ import { EXPORT_ACTION_BTN, EDIT_RULE_ACTION_BTN, NEXT_BTN, + ASYNC_LOADING_PROGRESS, + RULE_AUTO_REFRESH_IDLE_MODAL, + RULE_AUTO_REFRESH_IDLE_MODAL_CONTINUE, } from '../screens/alerts_detection_rules'; import { ALL_ACTIONS, DELETE_RULE } from '../screens/rule_details'; @@ -66,8 +68,8 @@ export const exportFirstRule = () => { export const filterByCustomRules = () => { cy.get(CUSTOM_RULES_BTN).click({ force: true }); - cy.get(LOADING_SPINNER).should('exist'); - cy.get(LOADING_SPINNER).should('not.exist'); + cy.get(ASYNC_LOADING_PROGRESS).should('exist'); + cy.get(ASYNC_LOADING_PROGRESS).should('not.exist'); }; export const goToCreateNewRule = () => { @@ -119,6 +121,32 @@ export const waitForRuleToBeActivated = () => { }; export const waitForRulesToBeLoaded = () => { - cy.get(LOADING_SPINNER).should('exist'); - cy.get(LOADING_SPINNER).should('not.exist'); + cy.get(ASYNC_LOADING_PROGRESS).should('exist'); + cy.get(ASYNC_LOADING_PROGRESS).should('not.exist'); +}; + +// when using, ensure you've called cy.clock prior in test +export const checkAutoRefresh = (ms: number, condition: string) => { + cy.get(ASYNC_LOADING_PROGRESS).should('not.be.visible'); + cy.tick(ms); + cy.get(ASYNC_LOADING_PROGRESS).should(condition); +}; + +export const dismissAllRulesIdleModal = () => { + cy.get(RULE_AUTO_REFRESH_IDLE_MODAL_CONTINUE) + .eq(1) + .should('exist') + .click({ force: true, multiple: true }); + cy.get(RULE_AUTO_REFRESH_IDLE_MODAL).should('not.be.visible'); +}; + +export const checkAllRulesIdleModal = (condition: string) => { + cy.tick(2700000); + cy.get(RULE_AUTO_REFRESH_IDLE_MODAL).should(condition); +}; + +export const resetAllRulesIdleModalTimeout = () => { + cy.tick(2000000); + cy.window().trigger('mousemove', { force: true }); + cy.tick(700000); }; diff --git a/x-pack/plugins/security_solution/cypress/tasks/common.ts b/x-pack/plugins/security_solution/cypress/tasks/common.ts index e16db54599981..bb009f34b02d6 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/common.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/common.ts @@ -23,14 +23,14 @@ export const drag = (subject: JQuery) => { clientY: subjectLocation.top, force: true, }) - .wait(3000) + .wait(300) .trigger('mousemove', { button: primaryButton, clientX: subjectLocation.left + dndSloppyClickDetectionThreshold, clientY: subjectLocation.top, force: true, }) - .wait(3000); + .wait(300); }; /** Drags the subject being dragged on the specified drop target, but does not drop it */ @@ -42,11 +42,17 @@ export const dragWithoutDrop = (dropTarget: JQuery) => { /** "Drops" the subject being dragged on the specified drop target */ export const drop = (dropTarget: JQuery) => { + const targetLocation = dropTarget[0].getBoundingClientRect(); cy.wrap(dropTarget) - .trigger('mousemove', { button: primaryButton, force: true }) - .wait(3000) + .trigger('mousemove', { + button: primaryButton, + clientX: targetLocation.left, + clientY: targetLocation.top, + force: true, + }) + .wait(300) .trigger('mouseup', { force: true }) - .wait(3000); + .wait(300); }; export const reload = (afterReload: () => void) => { diff --git a/x-pack/plugins/security_solution/cypress/tasks/hosts/events.ts b/x-pack/plugins/security_solution/cypress/tasks/hosts/events.ts index 226178cd92f18..401a78767ac57 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/hosts/events.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/hosts/events.ts @@ -8,6 +8,7 @@ import { drag, drop } from '../common'; import { CLOSE_MODAL, EVENTS_VIEWER_FIELDS_BUTTON, + EVENTS_VIEWER_PAGINATION, FIELDS_BROWSER_CONTAINER, HOST_GEO_CITY_NAME_CHECKBOX, HOST_GEO_COUNTRY_NAME_CHECKBOX, @@ -16,6 +17,7 @@ import { SERVER_SIDE_EVENT_COUNT, } from '../../screens/hosts/events'; import { DRAGGABLE_HEADER } from '../../screens/timeline'; +import { REFRESH_BUTTON } from '../../screens/security_header'; export const addsHostGeoCityNameToHeader = () => { cy.get(HOST_GEO_CITY_NAME_CHECKBOX).check({ @@ -53,7 +55,9 @@ export const opensInspectQueryModal = () => { }; export const waitsForEventsToBeLoaded = () => { - cy.get(SERVER_SIDE_EVENT_COUNT).should('exist').invoke('text').should('not.equal', '0'); + cy.get(SERVER_SIDE_EVENT_COUNT).should('not.have.text', '0'); + cy.get(REFRESH_BUTTON).should('not.have.text', 'Updating'); + cy.get(EVENTS_VIEWER_PAGINATION).should('exist'); }; export const dragAndDropColumn = ({ diff --git a/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap index f2d2d23d60fb1..d3d20c7183570 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap @@ -7,7 +7,9 @@ exports[`HeaderSection it renders 1`] = ` - + = ({ @@ -57,10 +58,11 @@ const HeaderSectionComponent: React.FC = ({ title, titleSize = 'm', tooltip, + growLeftSplit = true, }) => (
- + diff --git a/x-pack/plugins/security_solution/public/common/components/last_updated/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/last_updated/index.test.tsx new file mode 100644 index 0000000000000..db42794448c53 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/last_updated/index.test.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mount } from 'enzyme'; +import { I18nProvider } from '@kbn/i18n/react'; + +import { LastUpdatedAt } from './'; + +describe('LastUpdatedAt', () => { + beforeEach(() => { + Date.now = jest.fn().mockReturnValue(1603995369774); + }); + + test('it renders correct relative time', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.text()).toEqual(' Updated 2 minutes ago'); + }); + + test('it only renders icon if "compact" is true', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.text()).toEqual(''); + expect(wrapper.find('[data-test-subj="last-updated-at-clock-icon"]').exists()).toBeTruthy(); + }); + + test('it renders updating text if "showUpdating" is true', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.text()).toEqual(' Updating...'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/last_updated/index.tsx b/x-pack/plugins/security_solution/public/common/components/last_updated/index.tsx new file mode 100644 index 0000000000000..ef4ff0123dd1c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/last_updated/index.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiIcon, EuiText, EuiToolTip } from '@elastic/eui'; +import { FormattedRelative } from '@kbn/i18n/react'; +import React, { useEffect, useMemo, useState } from 'react'; + +import * as i18n from './translations'; + +interface LastUpdatedAtProps { + compact?: boolean; + updatedAt: number; + showUpdating?: boolean; +} + +export const Updated = React.memo<{ date: number; prefix: string; updatedAt: number }>( + ({ date, prefix, updatedAt }) => ( + <> + {prefix} + { + + } + + ) +); + +Updated.displayName = 'Updated'; + +const prefix = ` ${i18n.UPDATED} `; + +export const LastUpdatedAt = React.memo( + ({ compact = false, updatedAt, showUpdating = false }) => { + const [date, setDate] = useState(Date.now()); + + function tick() { + setDate(Date.now()); + } + + useEffect(() => { + const timerID = setInterval(() => tick(), 10000); + return () => { + clearInterval(timerID); + }; + }, []); + + const updateText = useMemo(() => { + if (showUpdating) { + return {i18n.UPDATING}; + } + + if (!compact) { + return ; + } + + return null; + }, [compact, date, showUpdating, updatedAt]); + + return ( + + + + } + > + + + {updateText} + + + ); + } +); + +LastUpdatedAt.displayName = 'LastUpdatedAt'; diff --git a/x-pack/plugins/security_solution/public/common/components/last_updated/translations.ts b/x-pack/plugins/security_solution/public/common/components/last_updated/translations.ts new file mode 100644 index 0000000000000..77278563b24d5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/last_updated/translations.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const UPDATING = i18n.translate('xpack.securitySolution.lastUpdated.updating', { + defaultMessage: 'Updating...', +}); + +export const UPDATED = i18n.translate('xpack.securitySolution.lastUpdated.updated', { + defaultMessage: 'Updated', +}); diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts index 06c152b94cfd8..38ae49ba3b19c 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts @@ -27,6 +27,10 @@ import { DEFAULT_REFRESH_RATE_INTERVAL, DEFAULT_TIME_RANGE, DEFAULT_TO, + DEFAULT_RULES_TABLE_REFRESH_SETTING, + DEFAULT_RULE_REFRESH_INTERVAL_ON, + DEFAULT_RULE_REFRESH_INTERVAL_VALUE, + DEFAULT_RULE_REFRESH_IDLE_VALUE, } from '../../../../common/constants'; import { StartServices } from '../../../types'; import { createSecuritySolutionStorageMock } from '../../mock/mock_local_storage'; @@ -48,6 +52,11 @@ const mockUiSettings: Record = { [DEFAULT_DATE_FORMAT_TZ]: 'UTC', [DEFAULT_DATE_FORMAT]: 'MMM D, YYYY @ HH:mm:ss.SSS', [DEFAULT_DARK_MODE]: false, + [DEFAULT_RULES_TABLE_REFRESH_SETTING]: { + on: DEFAULT_RULE_REFRESH_INTERVAL_ON, + value: DEFAULT_RULE_REFRESH_INTERVAL_VALUE, + idleTimeout: DEFAULT_RULE_REFRESH_IDLE_VALUE, + }, }; export const createUseUiSettingMock = () => (key: string, defaultValue?: unknown): unknown => { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/translations.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/translations.tsx index 164b1df8463e6..221963767caad 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/translations.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/translations.tsx @@ -95,7 +95,7 @@ export const THREAT_MATCH_INDEX_HELPER_TEXT = i18n.translate( export const THREAT_MATCH_REQUIRED = i18n.translate( 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.customThreatQueryFieldRequiredError', { - defaultMessage: 'At least one threat match is required.', + defaultMessage: 'At least one indicator match is required.', } ); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx index 6800743db738e..2b03d6dd4de36 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx @@ -210,7 +210,7 @@ export const getColumns = ({ getEmptyTagValue() ) : ( - + ); }, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx index 1a4c2d405dca3..be42d7b3212fd 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx @@ -6,13 +6,21 @@ import React from 'react'; import { shallow, mount } from 'enzyme'; +import { waitFor } from '@testing-library/react'; import '../../../../../common/mock/match_media'; import '../../../../../common/mock/formatted_relative'; -import { TestProviders } from '../../../../../common/mock'; -import { waitFor } from '@testing-library/react'; import { AllRules } from './index'; -import { useKibana } from '../../../../../common/lib/kibana'; +import { useKibana, useUiSetting$ } from '../../../../../common/lib/kibana'; +import { useRules, useRulesStatuses } from '../../../../containers/detection_engine/rules'; +import { TestProviders } from '../../../../../common/mock'; +import { createUseUiSetting$Mock } from '../../../../../common/lib/kibana/kibana_react.mock'; +import { + DEFAULT_RULE_REFRESH_INTERVAL_ON, + DEFAULT_RULE_REFRESH_INTERVAL_VALUE, + DEFAULT_RULE_REFRESH_IDLE_VALUE, + DEFAULT_RULES_TABLE_REFRESH_SETTING, +} from '../../../../../../common/constants'; jest.mock('react-router-dom', () => { const original = jest.requireActual('react-router-dom'); @@ -27,66 +35,33 @@ jest.mock('react-router-dom', () => { jest.mock('../../../../../common/components/link_to'); jest.mock('../../../../../common/lib/kibana'); +jest.mock('../../../../containers/detection_engine/rules'); const useKibanaMock = useKibana as jest.Mocked; +const mockUseUiSetting$ = useUiSetting$ as jest.Mock; -jest.mock('./reducer', () => { - return { - allRulesReducer: jest.fn().mockReturnValue(() => ({ - exportRuleIds: [], - filterOptions: { - filter: 'some filter', - sortField: 'some sort field', - sortOrder: 'desc', - }, - loadingRuleIds: [], - loadingRulesAction: null, - pagination: { - page: 1, - perPage: 20, - total: 1, - }, - rules: [ - { - actions: [], - created_at: '2020-02-14T19:49:28.178Z', - created_by: 'elastic', - description: 'jibber jabber', - enabled: false, - false_positives: [], - filters: [], - from: 'now-660s', - id: 'rule-id-1', - immutable: true, - index: ['endgame-*'], - interval: '10m', - language: 'kuery', - max_signals: 100, - name: 'Credential Dumping - Detected - Elastic Endpoint', - output_index: '.siem-signals-default', - query: 'host.name:*', - references: [], - risk_score: 73, - rule_id: '571afc56-5ed9-465d-a2a9-045f099f6e7e', - severity: 'high', - tags: ['Elastic', 'Endpoint'], - threat: [], - throttle: null, - to: 'now', - type: 'query', - updated_at: '2020-02-14T19:49:28.320Z', - updated_by: 'elastic', - version: 1, - }, - ], - selectedRuleIds: [], - })), - }; -}); +describe('AllRules', () => { + const mockRefetchRulesData = jest.fn(); -jest.mock('../../../../containers/detection_engine/rules', () => { - return { - useRules: jest.fn().mockReturnValue([ + beforeEach(() => { + jest.useFakeTimers(); + + mockUseUiSetting$.mockImplementation((key, defaultValue) => { + const useUiSetting$Mock = createUseUiSetting$Mock(); + + return key === DEFAULT_RULES_TABLE_REFRESH_SETTING + ? [ + { + on: DEFAULT_RULE_REFRESH_INTERVAL_ON, + value: DEFAULT_RULE_REFRESH_INTERVAL_VALUE, + idleTimeout: DEFAULT_RULE_REFRESH_IDLE_VALUE, + }, + jest.fn(), + ] + : useUiSetting$Mock(key, defaultValue); + }); + + (useRules as jest.Mock).mockReturnValue([ false, { page: 1, @@ -126,8 +101,10 @@ jest.mock('../../../../containers/detection_engine/rules', () => { }, ], }, - ]), - useRulesStatuses: jest.fn().mockReturnValue({ + mockRefetchRulesData, + ]); + + (useRulesStatuses as jest.Mock).mockReturnValue({ loading: false, rulesStatuses: [ { @@ -150,21 +127,8 @@ jest.mock('../../../../containers/detection_engine/rules', () => { name: 'Test rule', }, ], - }), - }; -}); - -jest.mock('react-router-dom', () => { - const originalModule = jest.requireActual('react-router-dom'); - - return { - ...originalModule, - useHistory: jest.fn(), - }; -}); + }); -describe('AllRules', () => { - beforeEach(() => { useKibanaMock().services.application.capabilities = { navLinks: {}, management: {}, @@ -172,6 +136,12 @@ describe('AllRules', () => { actions: { show: true }, }; }); + + afterEach(() => { + jest.clearAllTimers(); + jest.clearAllMocks(); + }); + it('renders correctly', () => { const wrapper = shallow( { expect(wrapper.find('[title="All rules"]')).toHaveLength(1); }); + it('it pulls from uiSettings to determine default refresh values', async () => { + mount( + + + + ); + + await waitFor(() => { + expect(mockRefetchRulesData).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(DEFAULT_RULE_REFRESH_INTERVAL_VALUE); + expect(mockRefetchRulesData).toHaveBeenCalledTimes(1); + }); + }); + + // refresh functionality largely tested in cypress tests + it('it pulls from storage and does not set an auto refresh interval if storage indicates refresh is paused', async () => { + mockUseUiSetting$.mockImplementation(() => [ + { + on: false, + value: DEFAULT_RULE_REFRESH_INTERVAL_VALUE, + idleTimeout: DEFAULT_RULE_REFRESH_IDLE_VALUE, + }, + jest.fn(), + ]); + + const wrapper = mount( + + + + ); + + await waitFor(() => { + expect(mockRefetchRulesData).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(DEFAULT_RULE_REFRESH_INTERVAL_VALUE); + expect(mockRefetchRulesData).not.toHaveBeenCalled(); + + wrapper.find('[data-test-subj="refreshSettings"] button').first().simulate('click'); + + wrapper.find('[data-test-subj="refreshSettingsSwitch"]').first().simulate('click'); + + jest.advanceTimersByTime(DEFAULT_RULE_REFRESH_INTERVAL_VALUE); + expect(mockRefetchRulesData).not.toHaveBeenCalled(); + }); + }); + describe('rules tab', () => { - it('renders correctly', async () => { + it('renders all rules tab by default', async () => { const wrapper = mount( { /> ); - const monitoringTab = wrapper.find('[data-test-subj="allRulesTableTab-monitoring"] button'); - monitoringTab.simulate('click'); await waitFor(() => { + const monitoringTab = wrapper.find('[data-test-subj="allRulesTableTab-monitoring"] button'); + monitoringTab.simulate('click'); + wrapper.update(); expect(wrapper.exists('[data-test-subj="monitoring-table"]')).toBeTruthy(); expect(wrapper.exists('[data-test-subj="rules-table"]')).toBeFalsy(); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx index 86b3daddd6c19..663a4bb242c06 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx @@ -6,15 +6,18 @@ import { EuiBasicTable, - EuiContextMenuPanel, EuiLoadingContent, EuiSpacer, EuiTab, EuiTabs, + EuiProgress, + EuiOverlayMask, + EuiConfirmModal, } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; import { useHistory } from 'react-router-dom'; import uuid from 'uuid'; +import { debounce } from 'lodash/fp'; import { useRules, @@ -27,14 +30,7 @@ import { RulesSortingFields, } from '../../../../containers/detection_engine/rules'; import { HeaderSection } from '../../../../../common/components/header_section'; -import { - UtilityBar, - UtilityBarAction, - UtilityBarGroup, - UtilityBarSection, - UtilityBarText, -} from '../../../../../common/components/utility_bar'; -import { useKibana } from '../../../../../common/lib/kibana'; +import { useKibana, useUiSetting$ } from '../../../../../common/lib/kibana'; import { useStateToaster } from '../../../../../common/components/toasters'; import { Loader } from '../../../../../common/components/loader'; import { Panel } from '../../../../../common/components/panel'; @@ -55,6 +51,9 @@ import { hasMlLicense } from '../../../../../../common/machine_learning/has_ml_l import { SecurityPageName } from '../../../../../app/types'; import { useFormatUrl } from '../../../../../common/components/link_to'; import { isBoolean } from '../../../../../common/utils/privileges'; +import { AllRulesUtilityBar } from './utility_bar'; +import { LastUpdatedAt } from '../../../../../common/components/last_updated'; +import { DEFAULT_RULES_TABLE_REFRESH_SETTING } from '../../../../../../common/constants'; const INITIAL_SORT_FIELD = 'enabled'; const initialState: State = { @@ -73,6 +72,9 @@ const initialState: State = { }, rules: [], selectedRuleIds: [], + lastUpdated: 0, + showIdleModal: false, + isRefreshOn: true, }; interface AllRulesProps { @@ -129,6 +131,18 @@ export const AllRules = React.memo( }) => { const [initLoading, setInitLoading] = useState(true); const tableRef = useRef(); + const { + services: { + application: { + capabilities: { actions }, + }, + }, + } = useKibana(); + const [defaultAutoRefreshSetting] = useUiSetting$<{ + on: boolean; + value: number; + idleTimeout: number; + }>(DEFAULT_RULES_TABLE_REFRESH_SETTING); const [ { exportRuleIds, @@ -138,9 +152,16 @@ export const AllRules = React.memo( pagination, rules, selectedRuleIds, + lastUpdated, + showIdleModal, + isRefreshOn, }, dispatch, - ] = useReducer(allRulesReducer(tableRef), initialState); + ] = useReducer(allRulesReducer(tableRef), { + ...initialState, + lastUpdated: Date.now(), + isRefreshOn: defaultAutoRefreshSetting.on, + }); const { loading: isLoadingRulesStatuses, rulesStatuses } = useRulesStatuses(rules); const history = useHistory(); const [, dispatchToaster] = useStateToaster(); @@ -159,6 +180,26 @@ export const AllRules = React.memo( }); }, []); + const setShowIdleModal = useCallback((show: boolean) => { + dispatch({ + type: 'setShowIdleModal', + show, + }); + }, []); + + const setLastRefreshDate = useCallback(() => { + dispatch({ + type: 'setLastRefreshDate', + }); + }, []); + + const setAutoRefreshOn = useCallback((on: boolean) => { + dispatch({ + type: 'setAutoRefreshOn', + on, + }); + }, []); + const [isLoadingRules, , reFetchRulesData] = useRules({ pagination, filterOptions, @@ -181,34 +222,25 @@ export const AllRules = React.memo( rulesNotInstalled, rulesNotUpdated ); - const { - services: { - application: { - capabilities: { actions }, - }, - }, - } = useKibana(); const hasActionsPrivileges = useMemo(() => (isBoolean(actions.show) ? actions.show : true), [ actions, ]); const getBatchItemsPopoverContent = useCallback( - (closePopover: () => void) => ( - - ), + (closePopover: () => void): JSX.Element[] => { + return getBatchItems({ + closePopover, + dispatch, + dispatchToaster, + hasMlPermissions, + hasActionsPrivileges, + loadingRuleIds, + selectedRuleIds, + reFetchRules: reFetchRulesData, + rules, + }); + }, [ dispatch, dispatchToaster, @@ -328,6 +360,94 @@ export const AllRules = React.memo( return false; }, [loadingRuleIds, loadingRulesAction]); + const handleRefreshData = useCallback((): void => { + if (reFetchRulesData != null && !isLoadingAnActionOnRule) { + reFetchRulesData(true); + setLastRefreshDate(); + } + }, [reFetchRulesData, isLoadingAnActionOnRule, setLastRefreshDate]); + + const handleResetIdleTimer = useCallback((): void => { + if (isRefreshOn) { + setShowIdleModal(true); + setAutoRefreshOn(false); + } + }, [setShowIdleModal, setAutoRefreshOn, isRefreshOn]); + + const debounceResetIdleTimer = useMemo(() => { + return debounce(defaultAutoRefreshSetting.idleTimeout, handleResetIdleTimer); + }, [handleResetIdleTimer, defaultAutoRefreshSetting.idleTimeout]); + + useEffect(() => { + const interval = setInterval(() => { + if (isRefreshOn) { + handleRefreshData(); + } + }, defaultAutoRefreshSetting.value); + + return () => { + clearInterval(interval); + }; + }, [isRefreshOn, handleRefreshData, defaultAutoRefreshSetting.value]); + + const handleIdleModalContinue = useCallback((): void => { + setShowIdleModal(false); + handleRefreshData(); + setAutoRefreshOn(true); + }, [setShowIdleModal, setAutoRefreshOn, handleRefreshData]); + + const handleAutoRefreshSwitch = useCallback( + (refreshOn: boolean) => { + if (refreshOn) { + handleRefreshData(); + } + setAutoRefreshOn(refreshOn); + }, + [setAutoRefreshOn, handleRefreshData] + ); + + useEffect(() => { + debounceResetIdleTimer(); + + window.addEventListener('mousemove', debounceResetIdleTimer, { passive: true }); + window.addEventListener('keydown', debounceResetIdleTimer); + + return () => { + window.removeEventListener('mousemove', debounceResetIdleTimer); + window.removeEventListener('keydown', debounceResetIdleTimer); + }; + }, [handleResetIdleTimer, debounceResetIdleTimer]); + + const shouldShowRulesTable = useMemo( + (): boolean => showRulesTable({ rulesCustomInstalled, rulesInstalled }) && !initLoading, + [initLoading, rulesCustomInstalled, rulesInstalled] + ); + + const shouldShowPrepackagedRulesPrompt = useMemo( + (): boolean => + rulesCustomInstalled != null && + rulesCustomInstalled === 0 && + prePackagedRuleStatus === 'ruleNotInstalled' && + !initLoading, + [initLoading, prePackagedRuleStatus, rulesCustomInstalled] + ); + + const handleGenericDownloaderSuccess = useCallback( + (exportCount) => { + dispatch({ type: 'loadingRuleIds', ids: [], actionType: null }); + dispatchToaster({ + type: 'addToaster', + toast: { + id: uuid.v4(), + title: i18n.SUCCESSFULLY_EXPORTED_RULES(exportCount), + color: 'success', + iconType: 'check', + }, + }); + }, + [dispatchToaster] + ); + const tabs = useMemo( () => ( @@ -353,27 +473,37 @@ export const AllRules = React.memo( { - dispatch({ type: 'loadingRuleIds', ids: [], actionType: null }); - dispatchToaster({ - type: 'addToaster', - toast: { - id: uuid.v4(), - title: i18n.SUCCESSFULLY_EXPORTED_RULES(exportCount), - color: 'success', - iconType: 'check', - }, - }); - }} + onExportSuccess={handleGenericDownloaderSuccess} exportSelectedData={exportRules} /> {tabs} - + <> - + {(isLoadingRules || isLoadingRulesStatuses) && ( + + )} + + } + > ( /> - {(loading || isLoadingRules || isLoadingAnActionOnRule || isLoadingRulesStatuses) && - !initLoading && ( - - )} - {rulesCustomInstalled != null && - rulesCustomInstalled === 0 && - prePackagedRuleStatus === 'ruleNotInstalled' && - !initLoading && ( - - )} + {isLoadingAnActionOnRule && !initLoading && ( + + )} + {shouldShowPrepackagedRulesPrompt && ( + + )} {initLoading && ( )} - {showRulesTable({ rulesCustomInstalled, rulesInstalled }) && !initLoading && ( + {showIdleModal && ( + + +

{i18n.REFRESH_PROMPT_BODY}

+
+
+ )} + {shouldShowRulesTable && ( <> - - - - - {i18n.SHOWING_RULES(pagination.total ?? 0)} - - - - - {i18n.SELECTED_RULES(selectedRuleIds.length)} - {!hasNoPermissions && ( - - {i18n.BATCH_ACTIONS} - - )} - reFetchRulesData(true)} - > - {i18n.REFRESH} - - - - + { + let reducer: (state: State, action: Action) => State; + + beforeEach(() => { + jest.useFakeTimers(); + jest + .spyOn(global.Date, 'now') + .mockImplementationOnce(() => new Date('2020-10-31T11:01:58.135Z').valueOf()); + reducer = allRulesReducer({ current: undefined }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('#exportRuleIds', () => { + test('should update state with rules to be exported', () => { + const { loadingRuleIds, loadingRulesAction, exportRuleIds } = reducer(initialState, { + type: 'exportRuleIds', + ids: ['123', '456'], + }); + + expect(loadingRuleIds).toEqual(['123', '456']); + expect(exportRuleIds).toEqual(['123', '456']); + expect(loadingRulesAction).toEqual('export'); + }); + }); + + describe('#loadingRuleIds', () => { + test('should update state with rule ids with a pending action', () => { + const { loadingRuleIds, loadingRulesAction } = reducer(initialState, { + type: 'loadingRuleIds', + ids: ['123', '456'], + actionType: 'enable', + }); + + expect(loadingRuleIds).toEqual(['123', '456']); + expect(loadingRulesAction).toEqual('enable'); + }); + + test('should update loadingIds to empty array if action is null', () => { + const { loadingRuleIds, loadingRulesAction } = reducer(initialState, { + type: 'loadingRuleIds', + ids: ['123', '456'], + actionType: null, + }); + + expect(loadingRuleIds).toEqual([]); + expect(loadingRulesAction).toBeNull(); + }); + + test('should append rule ids to any existing loading ids', () => { + const { loadingRuleIds, loadingRulesAction } = reducer( + { ...initialState, loadingRuleIds: ['abc'] }, + { + type: 'loadingRuleIds', + ids: ['123', '456'], + actionType: 'duplicate', + } + ); + + expect(loadingRuleIds).toEqual(['abc', '123', '456']); + expect(loadingRulesAction).toEqual('duplicate'); + }); + }); + + describe('#selectedRuleIds', () => { + test('should update state with selected rule ids', () => { + const { selectedRuleIds } = reducer(initialState, { + type: 'selectedRuleIds', + ids: ['123', '456'], + }); + + expect(selectedRuleIds).toEqual(['123', '456']); + }); + }); + + describe('#setRules', () => { + test('should update rules and reset loading/selected rule ids', () => { + const { selectedRuleIds, loadingRuleIds, loadingRulesAction, pagination, rules } = reducer( + initialState, + { + type: 'setRules', + rules: [mockRule('someRuleId')], + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + } + ); + + expect(rules).toEqual([mockRule('someRuleId')]); + expect(selectedRuleIds).toEqual([]); + expect(loadingRuleIds).toEqual([]); + expect(loadingRulesAction).toBeNull(); + expect(pagination).toEqual({ + page: 1, + perPage: 20, + total: 0, + }); + }); + }); + + describe('#updateRules', () => { + test('should return existing and new rules', () => { + const existingRule = { ...mockRule('123'), rule_id: 'rule-123' }; + const { rules, loadingRulesAction } = reducer( + { ...initialState, rules: [existingRule] }, + { + type: 'updateRules', + rules: [mockRule('someRuleId')], + } + ); + + expect(rules).toEqual([existingRule, mockRule('someRuleId')]); + expect(loadingRulesAction).toBeNull(); + }); + + test('should return updated rule', () => { + const updatedRule = { ...mockRule('someRuleId'), description: 'updated rule' }; + const { rules, loadingRulesAction } = reducer( + { ...initialState, rules: [mockRule('someRuleId')] }, + { + type: 'updateRules', + rules: [updatedRule], + } + ); + + expect(rules).toEqual([updatedRule]); + expect(loadingRulesAction).toBeNull(); + }); + + test('should return updated existing loading rule ids', () => { + const existingRule = { ...mockRule('someRuleId'), id: '123', rule_id: 'rule-123' }; + const { loadingRuleIds, loadingRulesAction } = reducer( + { + ...initialState, + rules: [existingRule], + loadingRuleIds: ['123'], + loadingRulesAction: 'enable', + }, + { + type: 'updateRules', + rules: [mockRule('someRuleId')], + } + ); + + expect(loadingRuleIds).toEqual(['123']); + expect(loadingRulesAction).toEqual('enable'); + }); + }); + + describe('#updateFilterOptions', () => { + test('should return existing and new rules', () => { + const paginationMock: PaginationOptions = { + page: 1, + perPage: 20, + total: 0, + }; + const filterMock: FilterOptions = { + filter: 'host.name:*', + sortField: 'enabled', + sortOrder: 'desc', + }; + const { filterOptions, pagination } = reducer(initialState, { + type: 'updateFilterOptions', + filterOptions: filterMock, + pagination: paginationMock, + }); + + expect(filterOptions).toEqual(filterMock); + expect(pagination).toEqual(paginationMock); + }); + }); + + describe('#failure', () => { + test('should reset rules value to empty array', () => { + const { rules } = reducer(initialState, { + type: 'failure', + }); + + expect(rules).toEqual([]); + }); + }); + + describe('#setLastRefreshDate', () => { + test('should update last refresh date with current date', () => { + const { lastUpdated } = reducer(initialState, { + type: 'setLastRefreshDate', + }); + + expect(lastUpdated).toEqual(1604142118135); + }); + }); + + describe('#setShowIdleModal', () => { + test('should hide idle modal and restart refresh if "show" is false', () => { + const { showIdleModal, isRefreshOn } = reducer(initialState, { + type: 'setShowIdleModal', + show: false, + }); + + expect(showIdleModal).toBeFalsy(); + expect(isRefreshOn).toBeTruthy(); + }); + + test('should show idle modal and pause refresh if "show" is true', () => { + const { showIdleModal, isRefreshOn } = reducer(initialState, { + type: 'setShowIdleModal', + show: true, + }); + + expect(showIdleModal).toBeTruthy(); + expect(isRefreshOn).toBeFalsy(); + }); + }); + + describe('#setAutoRefreshOn', () => { + test('should pause auto refresh if "paused" is true', () => { + const { isRefreshOn } = reducer(initialState, { + type: 'setAutoRefreshOn', + on: true, + }); + + expect(isRefreshOn).toBeTruthy(); + }); + + test('should resume auto refresh if "paused" is false', () => { + const { isRefreshOn } = reducer(initialState, { + type: 'setAutoRefreshOn', + on: false, + }); + + expect(isRefreshOn).toBeFalsy(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/reducer.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/reducer.ts index ff9b41bed06f5..d603e5791f5ce 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/reducer.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/reducer.ts @@ -20,6 +20,9 @@ export interface State { pagination: PaginationOptions; rules: Rule[]; selectedRuleIds: string[]; + lastUpdated: number; + showIdleModal: boolean; + isRefreshOn: boolean; } export type Action = @@ -33,7 +36,10 @@ export type Action = filterOptions: Partial; pagination: Partial; } - | { type: 'failure' }; + | { type: 'failure' } + | { type: 'setLastRefreshDate' } + | { type: 'setShowIdleModal'; show: boolean } + | { type: 'setAutoRefreshOn'; on: boolean }; export const allRulesReducer = ( tableRef: React.MutableRefObject | undefined> @@ -85,27 +91,24 @@ export const allRulesReducer = ( }; } case 'updateRules': { - if (state.rules != null) { - const ruleIds = state.rules.map((r) => r.id); - const updatedRules = action.rules.reduce((rules, updatedRule) => { - let newRules = rules; - if (ruleIds.includes(updatedRule.id)) { - newRules = newRules.map((r) => (updatedRule.id === r.id ? updatedRule : r)); - } else { - newRules = [...newRules, updatedRule]; - } - return newRules; - }, state.rules); - const updatedRuleIds = action.rules.map((r) => r.id); - const newLoadingRuleIds = state.loadingRuleIds.filter((id) => !updatedRuleIds.includes(id)); - return { - ...state, - rules: updatedRules, - loadingRuleIds: newLoadingRuleIds, - loadingRulesAction: newLoadingRuleIds.length === 0 ? null : state.loadingRulesAction, - }; - } - return state; + const ruleIds = state.rules.map((r) => r.id); + const updatedRules = action.rules.reduce((rules, updatedRule) => { + let newRules = rules; + if (ruleIds.includes(updatedRule.id)) { + newRules = newRules.map((r) => (updatedRule.id === r.id ? updatedRule : r)); + } else { + newRules = [...newRules, updatedRule]; + } + return newRules; + }, state.rules); + const updatedRuleIds = action.rules.map((r) => r.id); + const newLoadingRuleIds = state.loadingRuleIds.filter((id) => !updatedRuleIds.includes(id)); + return { + ...state, + rules: updatedRules, + loadingRuleIds: newLoadingRuleIds, + loadingRulesAction: newLoadingRuleIds.length === 0 ? null : state.loadingRulesAction, + }; } case 'updateFilterOptions': { return { @@ -126,6 +129,25 @@ export const allRulesReducer = ( rules: [], }; } + case 'setLastRefreshDate': { + return { + ...state, + lastUpdated: Date.now(), + }; + } + case 'setShowIdleModal': { + return { + ...state, + showIdleModal: action.show, + isRefreshOn: !action.show, + }; + } + case 'setAutoRefreshOn': { + return { + ...state, + isRefreshOn: action.on, + }; + } default: return state; } diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.test.tsx index 92f69d79110d2..a8205c24dca65 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.test.tsx @@ -5,16 +5,47 @@ */ import React from 'react'; -import { shallow } from 'enzyme'; +import { mount } from 'enzyme'; +import { act } from '@testing-library/react'; import { RulesTableFilters } from './rules_table_filters'; describe('RulesTableFilters', () => { - it('renders correctly', () => { - const wrapper = shallow( - - ); + it('renders no numbers next to rule type button filter if none exist', async () => { + await act(async () => { + const wrapper = mount( + + ); - expect(wrapper.find('[data-test-subj="show-elastic-rules-filter-button"]')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="showElasticRulesFilterButton"]').at(0).text()).toEqual( + 'Elastic rules' + ); + expect(wrapper.find('[data-test-subj="showCustomRulesFilterButton"]').at(0).text()).toEqual( + 'Custom rules' + ); + }); + }); + + it('renders number of custom and prepackaged rules', async () => { + await act(async () => { + const wrapper = mount( + + ); + + expect(wrapper.find('[data-test-subj="showElasticRulesFilterButton"]').at(0).text()).toEqual( + 'Elastic rules (9)' + ); + expect(wrapper.find('[data-test-subj="showCustomRulesFilterButton"]').at(0).text()).toEqual( + 'Custom rules (10)' + ); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx index 0f201fcbaa441..0b83a8437cc1a 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx @@ -14,6 +14,7 @@ import { EuiFlexItem, } from '@elastic/eui'; import { isEqual } from 'lodash/fp'; + import * as i18n from '../../translations'; import { FilterOptions } from '../../../../../containers/detection_engine/rules'; @@ -76,7 +77,7 @@ const RulesTableFiltersComponent = ({ return ( - + @@ -102,7 +104,7 @@ const RulesTableFiltersComponent = ({ {i18n.ELASTIC_RULES} @@ -111,7 +113,7 @@ const RulesTableFiltersComponent = ({ <> {i18n.CUSTOM_RULES} diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.test.tsx new file mode 100644 index 0000000000000..3d49295bde50a --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.test.tsx @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { ThemeProvider } from 'styled-components'; +import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; +import { waitFor } from '@testing-library/react'; + +import { AllRulesUtilityBar } from './utility_bar'; + +describe('AllRules', () => { + it('renders AllRulesUtilityBar total rules and selected rules', () => { + const wrapper = mount( + ({ eui: euiDarkVars, darkMode: true })}> + + + ); + + expect(wrapper.find('[data-test-subj="showingRules"]').at(0).text()).toEqual('Showing 4 rules'); + expect(wrapper.find('[data-test-subj="selectedRules"]').at(0).text()).toEqual( + 'Selected 1 rule' + ); + }); + + it('renders utility actions if user has permissions', () => { + const wrapper = mount( + ({ eui: euiDarkVars, darkMode: true })}> + + + ); + + expect(wrapper.find('[data-test-subj="bulkActions"]').exists()).toBeTruthy(); + }); + + it('renders no utility actions if user has no permissions', () => { + const wrapper = mount( + ({ eui: euiDarkVars, darkMode: true })}> + + + ); + + expect(wrapper.find('[data-test-subj="bulkActions"]').exists()).toBeFalsy(); + }); + + it('invokes refresh on refresh action click', () => { + const mockRefresh = jest.fn(); + const wrapper = mount( + ({ eui: euiDarkVars, darkMode: true })}> + + + ); + + wrapper.find('[data-test-subj="refreshRulesAction"] button').at(0).simulate('click'); + + expect(mockRefresh).toHaveBeenCalled(); + }); + + it('invokes onRefreshSwitch when auto refresh switch is clicked', async () => { + const mockSwitch = jest.fn(); + const wrapper = mount( + ({ eui: euiDarkVars, darkMode: true })}> + + + ); + + await waitFor(() => { + wrapper.find('[data-test-subj="refreshSettings"] button').first().simulate('click'); + wrapper.find('[data-test-subj="refreshSettingsSwitch"] button').first().simulate('click'); + expect(mockSwitch).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx new file mode 100644 index 0000000000000..3553dcc9b7c14 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx @@ -0,0 +1,118 @@ +/* + * 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 { EuiContextMenuPanel, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; +import React, { useCallback } from 'react'; + +import { + UtilityBar, + UtilityBarAction, + UtilityBarGroup, + UtilityBarSection, + UtilityBarText, +} from '../../../../../common/components/utility_bar'; +import * as i18n from '../translations'; + +interface AllRulesUtilityBarProps { + userHasNoPermissions: boolean; + numberSelectedRules: number; + paginationTotal: number; + isAutoRefreshOn: boolean; + onRefresh: (refreshRule: boolean) => void; + onGetBatchItemsPopoverContent: (closePopover: () => void) => JSX.Element[]; + onRefreshSwitch: (checked: boolean) => void; +} + +export const AllRulesUtilityBar = React.memo( + ({ + userHasNoPermissions, + onRefresh, + paginationTotal, + numberSelectedRules, + onGetBatchItemsPopoverContent, + isAutoRefreshOn, + onRefreshSwitch, + }) => { + const handleGetBatchItemsPopoverContent = useCallback( + (closePopover: () => void) => ( + + ), + [onGetBatchItemsPopoverContent] + ); + + const handleAutoRefreshSwitch = useCallback( + (closePopover: () => void) => (e: EuiSwitchEvent) => { + onRefreshSwitch(e.target.checked); + closePopover(); + }, + [onRefreshSwitch] + ); + + const handleGetRefreshSettingsPopoverContent = useCallback( + (closePopover: () => void) => ( + , + ]} + /> + ), + [isAutoRefreshOn, handleAutoRefreshSwitch] + ); + + return ( + + + + + {i18n.SHOWING_RULES(paginationTotal)} + + + + + + {i18n.SELECTED_RULES(numberSelectedRules)} + + {!userHasNoPermissions && ( + + {i18n.BATCH_ACTIONS} + + )} + + {i18n.REFRESH} + + + {i18n.REFRESH_RULE_POPOVER_LABEL} + + + + + ); + } +); + +AllRulesUtilityBar.displayName = 'AllRulesUtilityBar'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts index d20b97a98fbf5..38fb457185b67 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts @@ -554,3 +554,38 @@ export const IMPORT_FAILED_DETAILED = (ruleId: string, statusCode: number, messa defaultMessage: 'Rule ID: {ruleId}\n Status Code: {statusCode}\n Message: {message}', } ); + +export const REFRESH_PROMPT_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.refreshPromptTitle', + { + defaultMessage: 'Are you still there?', + } +); + +export const REFRESH_PROMPT_CONFIRM = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.refreshPromptConfirm', + { + defaultMessage: 'Continue', + } +); + +export const REFRESH_PROMPT_BODY = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.refreshPromptBody', + { + defaultMessage: 'Rule auto-refresh has been paused. Click "Continue" to resume.', + } +); + +export const REFRESH_RULE_POPOVER_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.refreshRulePopoverDescription', + { + defaultMessage: 'Automatically refresh table', + } +); + +export const REFRESH_RULE_POPOVER_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.refreshRulePopoverLabel', + { + defaultMessage: 'Refresh settings', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx index 4119127d5a108..f56d7d90cf2df 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx @@ -25,10 +25,10 @@ import styled from 'styled-components'; import { LoadingPanel } from '../../loading'; import { OnChangeItemsPerPage, OnChangePage } from '../events'; -import { LastUpdatedAt } from './last_updated'; import * as i18n from './translations'; import { useEventDetailsWidthContext } from '../../../../common/components/events_viewer/event_details_width_context'; import { useManageTimeline } from '../../manage_timeline'; +import { LastUpdatedAt } from '../../../../common/components/last_updated'; export const isCompactFooter = (width: number): boolean => width < 600; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/last_updated.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/last_updated.tsx deleted file mode 100644 index 06ece50690c09..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/last_updated.tsx +++ /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 { EuiIcon, EuiText, EuiToolTip } from '@elastic/eui'; -import { FormattedRelative } from '@kbn/i18n/react'; -import React, { useEffect, useState } from 'react'; - -import * as i18n from './translations'; - -interface LastUpdatedAtProps { - compact?: boolean; - updatedAt: number; -} - -export const Updated = React.memo<{ date: number; prefix: string; updatedAt: number }>( - ({ date, prefix, updatedAt }) => ( - <> - {prefix} - { - - } - - ) -); - -Updated.displayName = 'Updated'; - -const prefix = ` ${i18n.UPDATED} `; - -export const LastUpdatedAt = React.memo(({ compact = false, updatedAt }) => { - const [date, setDate] = useState(Date.now()); - - function tick() { - setDate(Date.now()); - } - - useEffect(() => { - const timerID = setInterval(() => tick(), 10000); - return () => { - clearInterval(timerID); - }; - }, []); - - return ( - - - - } - > - - - {!compact ? : null} - - - ); -}); - -LastUpdatedAt.displayName = 'LastUpdatedAt'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/translations.ts index f581d0757bc3c..016406d6bd061 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/translations.ts @@ -36,10 +36,6 @@ export const TOTAL_COUNT_OF_EVENTS = i18n.translate( } ); -export const UPDATED = i18n.translate('xpack.securitySolution.footer.updated', { - defaultMessage: 'Updated', -}); - export const AUTO_REFRESH_ACTIVE = i18n.translate( 'xpack.securitySolution.footer.autoRefreshActiveDescription', { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 94b820344b37c..773e84d9c88fc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -407,6 +407,8 @@ export const getResult = (): RuleAlertType => ({ note: '# Investigative notes', version: 1, exceptionsList: getListArrayMock(), + concurrentSearches: undefined, + itemsPerSearch: undefined, }, createdAt: new Date('2019-12-13T16:40:33.400Z'), updatedAt: new Date('2019-12-13T16:40:33.400Z'), diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts index 8c7a19869ce18..aa409580df965 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts @@ -102,6 +102,8 @@ export const createRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) => threat_mapping: threatMapping, threat_query: threatQuery, threat_language: threatLanguage, + concurrent_searches: concurrentSearches, + items_per_search: itemsPerSearch, threshold, throttle, timestamp_override: timestampOverride, @@ -193,6 +195,8 @@ export const createRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) => threatQuery, threatIndex, threatLanguage, + concurrentSearches, + itemsPerSearch, threshold, timestampOverride, references, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.ts index 6ba7bc78fbded..97c05b4626ddc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.ts @@ -85,6 +85,8 @@ export const createRulesRoute = (router: IRouter, ml: SetupPlugins['ml']): void threat_query: threatQuery, threat_mapping: threatMapping, threat_language: threatLanguage, + concurrent_searches: concurrentSearches, + items_per_search: itemsPerSearch, throttle, timestamp_override: timestampOverride, to, @@ -182,6 +184,8 @@ export const createRulesRoute = (router: IRouter, ml: SetupPlugins['ml']): void threatQuery, threatMapping, threatLanguage, + concurrentSearches, + itemsPerSearch, timestampOverride, references, note, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_status_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_status_route.test.ts index 4d992c6c7029d..4b75127af1bc7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_status_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_status_route.test.ts @@ -9,6 +9,7 @@ import { getFindResultStatus, ruleStatusRequest, getResult } from '../__mocks__/ import { serverMock, requestContextMock, requestMock } from '../__mocks__'; import { findRulesStatusesRoute } from './find_rules_status_route'; import { RuleStatusResponse } from '../../rules/types'; +import { AlertExecutionStatusErrorReasons } from '../../../../../../alerts/common'; jest.mock('../../signals/rule_status_service'); @@ -57,7 +58,7 @@ describe('find_statuses', () => { status: 'error', lastExecutionDate: failingExecutionRule.executionStatus.lastExecutionDate, error: { - reason: 'read', + reason: AlertExecutionStatusErrorReasons.Read, message: 'oops', }, }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts index 7cbcf25590921..688036c59c8ff 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -169,6 +169,8 @@ export const importRulesRoute = (router: IRouter, config: ConfigType, ml: SetupP threat_query: threatQuery, threat_mapping: threatMapping, threat_language: threatLanguage, + concurrent_searches: concurrentSearches, + items_per_search: itemsPerSearch, threshold, timestamp_override: timestampOverride, to, @@ -235,6 +237,8 @@ export const importRulesRoute = (router: IRouter, config: ConfigType, ml: SetupP threatQuery, threatMapping, threatLanguage, + concurrentSearches, + itemsPerSearch, timestampOverride, references, note, @@ -284,6 +288,8 @@ export const importRulesRoute = (router: IRouter, config: ConfigType, ml: SetupP threatQuery, threatMapping, threatLanguage, + concurrentSearches, + itemsPerSearch, references, note, version, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts index 4c310774ec72b..7dfb4daa1a0a2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts @@ -97,6 +97,8 @@ export const patchRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) => threat_query: threatQuery, threat_mapping: threatMapping, threat_language: threatLanguage, + concurrent_searches: concurrentSearches, + items_per_search: itemsPerSearch, timestamp_override: timestampOverride, throttle, references, @@ -162,6 +164,8 @@ export const patchRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) => threatQuery, threatMapping, threatLanguage, + concurrentSearches, + itemsPerSearch, timestampOverride, references, note, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts index dbdcd9844c0a7..aadb13ef54e72 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts @@ -83,6 +83,8 @@ export const patchRulesRoute = (router: IRouter, ml: SetupPlugins['ml']) => { threat_query: threatQuery, threat_mapping: threatMapping, threat_language: threatLanguage, + concurrent_searches: concurrentSearches, + items_per_search: itemsPerSearch, timestamp_override: timestampOverride, throttle, references, @@ -161,6 +163,8 @@ export const patchRulesRoute = (router: IRouter, ml: SetupPlugins['ml']) => { threatQuery, threatMapping, threatLanguage, + concurrentSearches, + itemsPerSearch, timestampOverride, references, note, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts index b93b3f319193f..f4a31c2bb456d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts @@ -102,6 +102,8 @@ export const updateRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) => threat_query: threatQuery, threat_mapping: threatMapping, threat_language: threatLanguage, + concurrent_searches: concurrentSearches, + items_per_search: itemsPerSearch, throttle, timestamp_override: timestampOverride, references, @@ -174,6 +176,8 @@ export const updateRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) => threatQuery, threatMapping, threatLanguage, + concurrentSearches, + itemsPerSearch, timestampOverride, references, note, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts index ea19fed5d6668..7ad525b67f7aa 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts @@ -86,6 +86,8 @@ export const updateRulesRoute = (router: IRouter, ml: SetupPlugins['ml']) => { threat_query: threatQuery, threat_mapping: threatMapping, threat_language: threatLanguage, + concurrent_searches: concurrentSearches, + items_per_search: itemsPerSearch, throttle, timestamp_override: timestampOverride, references, @@ -163,6 +165,8 @@ export const updateRulesRoute = (router: IRouter, ml: SetupPlugins['ml']) => { threatQuery, threatMapping, threatLanguage, + concurrentSearches, + itemsPerSearch, timestampOverride, references, note, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts index fb4ba855f6536..7360dc77aac22 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts @@ -151,6 +151,8 @@ export const transformAlertToRule = ( threat_query: alert.params.threatQuery, threat_mapping: alert.params.threatMapping, threat_language: alert.params.threatLanguage, + concurrent_searches: alert.params.concurrentSearches, + items_per_search: alert.params.itemsPerSearch, throttle: ruleActions?.ruleThrottle || 'no_actions', timestamp_override: alert.params.timestampOverride, note: alert.params.note, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/utils.test.ts index 25e47b38e8a56..b613061ac85f2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/utils.test.ts @@ -27,6 +27,7 @@ import { import { responseMock } from './__mocks__'; import { exampleRuleStatus, exampleFindRuleStatusResponse } from '../signals/__mocks__/es_results'; import { getResult } from './__mocks__/request_responses'; +import { AlertExecutionStatusErrorReasons } from '../../../../../alerts/common'; let alertsClient: ReturnType; @@ -464,7 +465,7 @@ describe('utils', () => { status: 'error', lastExecutionDate: foundRule.executionStatus.lastExecutionDate, error: { - reason: 'read', + reason: AlertExecutionStatusErrorReasons.Read, message: 'oops', }, }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.mock.ts index 271b1043ea568..68199c531a2fe 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.mock.ts @@ -43,6 +43,8 @@ export const getCreateRulesOptionsMock = (): CreateRulesOptions => ({ threatFilters: undefined, threatMapping: undefined, threatLanguage: undefined, + concurrentSearches: undefined, + itemsPerSearch: undefined, threatQuery: undefined, threatIndex: undefined, threshold: undefined, @@ -94,6 +96,8 @@ export const getCreateMlRulesOptionsMock = (): CreateRulesOptions => ({ threatMapping: undefined, threatQuery: undefined, threatLanguage: undefined, + concurrentSearches: undefined, + itemsPerSearch: undefined, threshold: undefined, timestampOverride: undefined, to: 'now', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts index 776882d0f8494..3c814ce7e6606 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts @@ -46,6 +46,8 @@ export const createRules = async ({ threatFilters, threatIndex, threatLanguage, + concurrentSearches, + itemsPerSearch, threatQuery, threatMapping, threshold, @@ -96,6 +98,8 @@ export const createRules = async ({ threatFilters, threatIndex, threatQuery, + concurrentSearches, + itemsPerSearch, threatMapping, threatLanguage, timestampOverride, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/install_prepacked_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/install_prepacked_rules.ts index 0a43c652234d0..4c01318f02cde 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/install_prepacked_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/install_prepacked_rules.ts @@ -51,6 +51,8 @@ export const installPrepackagedRules = ( threat_filters: threatFilters, threat_mapping: threatMapping, threat_language: threatLanguage, + concurrent_searches: concurrentSearches, + items_per_search: itemsPerSearch, threat_query: threatQuery, threat_index: threatIndex, threshold, @@ -103,6 +105,8 @@ export const installPrepackagedRules = ( threatFilters, threatMapping, threatLanguage, + concurrentSearches, + itemsPerSearch, threatQuery, threatIndex, threshold, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts index ef7cd35f28f1b..60f1d599470e3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts @@ -154,6 +154,8 @@ export const getPatchRulesOptionsMock = (): PatchRulesOptions => ({ threatQuery: undefined, threatMapping: undefined, threatLanguage: undefined, + concurrentSearches: undefined, + itemsPerSearch: undefined, timestampOverride: undefined, to: 'now', type: 'query', @@ -203,6 +205,8 @@ export const getPatchMlRulesOptionsMock = (): PatchRulesOptions => ({ threatQuery: undefined, threatMapping: undefined, threatLanguage: undefined, + concurrentSearches: undefined, + itemsPerSearch: undefined, timestampOverride: undefined, to: 'now', type: 'machine_learning', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts index 1982dcf9dd9b6..22b2593283696 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts @@ -49,6 +49,8 @@ export const patchRules = async ({ threatQuery, threatMapping, threatLanguage, + concurrentSearches, + itemsPerSearch, timestampOverride, to, type, @@ -97,6 +99,8 @@ export const patchRules = async ({ threatQuery, threatMapping, threatLanguage, + concurrentSearches, + itemsPerSearch, timestampOverride, to, type, @@ -141,6 +145,8 @@ export const patchRules = async ({ threatQuery, threatMapping, threatLanguage, + concurrentSearches, + itemsPerSearch, timestampOverride, to, type, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts index fb4763a982f43..f6ab3fb0c3ed2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts @@ -92,6 +92,8 @@ import { ThreatMappingOrUndefined, ThreatFiltersOrUndefined, ThreatLanguageOrUndefined, + ConcurrentSearchesOrUndefined, + ItemsPerSearchOrUndefined, } from '../../../../common/detection_engine/schemas/types/threat_mapping'; import { AlertsClient, PartialAlert } from '../../../../../alerts/server'; @@ -234,6 +236,8 @@ export interface CreateRulesOptions { threatIndex: ThreatIndexOrUndefined; threatQuery: ThreatQueryOrUndefined; threatMapping: ThreatMappingOrUndefined; + concurrentSearches: ConcurrentSearchesOrUndefined; + itemsPerSearch: ItemsPerSearchOrUndefined; threatLanguage: ThreatLanguageOrUndefined; timestampOverride: TimestampOverrideOrUndefined; to: To; @@ -284,6 +288,8 @@ export interface UpdateRulesOptions { threatIndex: ThreatIndexOrUndefined; threatQuery: ThreatQueryOrUndefined; threatMapping: ThreatMappingOrUndefined; + itemsPerSearch: ItemsPerSearchOrUndefined; + concurrentSearches: ConcurrentSearchesOrUndefined; threatLanguage: ThreatLanguageOrUndefined; timestampOverride: TimestampOverrideOrUndefined; to: To; @@ -327,6 +333,8 @@ export interface PatchRulesOptions { severityMapping: SeverityMappingOrUndefined; tags: TagsOrUndefined; threat: ThreatOrUndefined; + itemsPerSearch: ItemsPerSearchOrUndefined; + concurrentSearches: ConcurrentSearchesOrUndefined; threshold: ThresholdOrUndefined; threatFilters: ThreatFiltersOrUndefined; threatIndex: ThreatIndexOrUndefined; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts index c685c4198c119..3d4b27b74c0af 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts @@ -52,6 +52,8 @@ export const updatePrepackagedRules = async ( threat_query: threatQuery, threat_mapping: threatMapping, threat_language: threatLanguage, + concurrent_searches: concurrentSearches, + items_per_search: itemsPerSearch, timestamp_override: timestampOverride, references, version, @@ -107,6 +109,8 @@ export const updatePrepackagedRules = async ( threatQuery, threatMapping, threatLanguage, + concurrentSearches, + itemsPerSearch, references, version, note, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.mock.ts index a33651580ef22..34be0f6ad843d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.mock.ts @@ -49,6 +49,8 @@ export const getUpdateRulesOptionsMock = (): UpdateRulesOptions => ({ threatMapping: undefined, threatLanguage: undefined, timestampOverride: undefined, + concurrentSearches: undefined, + itemsPerSearch: undefined, to: 'now', type: 'query', references: ['http://www.example.com'], @@ -99,6 +101,8 @@ export const getUpdateMlRulesOptionsMock = (): UpdateRulesOptions => ({ threatMapping: undefined, threatLanguage: undefined, timestampOverride: undefined, + concurrentSearches: undefined, + itemsPerSearch: undefined, to: 'now', type: 'machine_learning', references: ['http://www.example.com'], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts index 3da921ed47f26..5168affca5c62 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts @@ -50,6 +50,8 @@ export const updateRules = async ({ threatQuery, threatMapping, threatLanguage, + concurrentSearches, + itemsPerSearch, timestampOverride, to, type, @@ -99,6 +101,8 @@ export const updateRules = async ({ threatQuery, threatMapping, threatLanguage, + concurrentSearches, + itemsPerSearch, timestampOverride, to, type, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.test.ts index 654383ff97c7a..8555af424ecd7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.test.ts @@ -60,6 +60,8 @@ describe('utils', () => { threatQuery: undefined, threatMapping: undefined, threatLanguage: undefined, + concurrentSearches: undefined, + itemsPerSearch: undefined, to: undefined, timestampOverride: undefined, type: undefined, @@ -108,6 +110,8 @@ describe('utils', () => { threatQuery: undefined, threatMapping: undefined, threatLanguage: undefined, + concurrentSearches: undefined, + itemsPerSearch: undefined, to: undefined, timestampOverride: undefined, type: undefined, @@ -158,6 +162,8 @@ describe('utils', () => { threatLanguage: undefined, to: undefined, timestampOverride: undefined, + concurrentSearches: undefined, + itemsPerSearch: undefined, type: undefined, references: undefined, version: undefined, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts index a9a100543b528..83d9e3fd3e59f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts @@ -43,6 +43,8 @@ import { } from '../../../../common/detection_engine/schemas/common/schemas'; import { PartialFilter } from '../types'; import { + ConcurrentSearchesOrUndefined, + ItemsPerSearchOrUndefined, ListArrayOrUndefined, ThreatFiltersOrUndefined, ThreatIndexOrUndefined, @@ -98,6 +100,8 @@ export interface UpdateProperties { threatQuery: ThreatQueryOrUndefined; threatMapping: ThreatMappingOrUndefined; threatLanguage: ThreatLanguageOrUndefined; + concurrentSearches: ConcurrentSearchesOrUndefined; + itemsPerSearch: ItemsPerSearchOrUndefined; timestampOverride: TimestampOverrideOrUndefined; to: ToOrUndefined; type: TypeOrUndefined; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/create_threat_data.sh b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/create_threat_data.sh index 23c1914387c44..4807afd71e8d2 100755 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/create_threat_data.sh +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/create_threat_data.sh @@ -12,7 +12,7 @@ set -e # Adds port mock data to a threat list for testing. # Example: ./create_threat_data.sh -# Example: ./create_threat_data.sh 1000 2000 +# Example: ./create_threat_data.sh 1 500 START=${1:-1} END=${2:-1000} @@ -22,7 +22,7 @@ do { curl -s -k \ -H "Content-Type: application/json" \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ - -X PUT ${ELASTICSEARCH_URL}/mock-threat-list/_doc/$i \ + -X PUT ${ELASTICSEARCH_URL}/mock-threat-list-1/_doc/$i \ --data " { \"@timestamp\": \"$(date -u +"%Y-%m-%dT%H:%M:%SZ")\", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/query_with_threat_mapping_perf.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/query_with_threat_mapping_perf.json new file mode 100644 index 0000000000000..c573db7fbca35 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/query_with_threat_mapping_perf.json @@ -0,0 +1,32 @@ +{ + "concurrent_searches": 10, + "items_per_search": 10, + "index": ["auditbeat-*", "endgame-*", "filebeat-*", "logs-*", "packetbeat-*", "winlogbeat-*"], + "name": "Indicator Match Concurrent Searches", + "description": "Does 100 Concurrent searches with 10 items per search", + "rule_id": "indicator_concurrent_search", + "risk_score": 1, + "severity": "high", + "type": "threat_match", + "query": "*:*", + "tags": ["concurrent_searches_test", "from_script"], + "threat_index": ["mock-threat-list-1"], + "threat_language": "kuery", + "threat_query": "*:*", + "threat_mapping": [ + { + "entries": [ + { + "field": "source.port", + "type": "mapping", + "value": "source.port" + }, + { + "field": "source.ip", + "type": "mapping", + "value": "source.ip" + } + ] + } + ] +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts index 4559a658c9583..92e6b9562d970 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts @@ -68,6 +68,8 @@ export const sampleRuleAlertParams = ( threat: undefined, version: 1, exceptionsList: getListArrayMock(), + concurrentSearches: undefined, + itemsPerSearch: undefined, }); export const sampleRuleSO = (): SavedObject => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.ts index cfe71f66395b0..50e740e81830f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.ts @@ -54,6 +54,8 @@ const signalSchema = schema.object({ threatQuery: schema.maybe(schema.string()), threatMapping: schema.maybe(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), threatLanguage: schema.maybe(schema.string()), + concurrentSearches: schema.maybe(schema.number()), + itemsPerSearch: schema.maybe(schema.number()), }); /** diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts index 415abc9d995fb..dc68e3949eb36 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts @@ -504,7 +504,7 @@ describe('rules_notification_alert_type', () => { await alert.executor(payload); expect(logger.error).toHaveBeenCalled(); expect(logger.error.mock.calls[0][0]).toContain( - 'An error occurred during rule execution: message: "Threat Match rule is missing threatQuery and/or threatIndex and/or threatMapping: threatQuery: "undefined" threatIndex: "undefined" threatMapping: "undefined"" name: "Detect Root/Admin Users" id: "04128c15-0d1b-4716-a4c5-46997ac7f3bd" rule id: "rule-1" signals index: ".siem-signals"' + 'An error occurred during rule execution: message: "Indicator match is missing threatQuery and/or threatIndex and/or threatMapping: threatQuery: "undefined" threatIndex: "undefined" threatMapping: "undefined"" name: "Detect Root/Admin Users" id: "04128c15-0d1b-4716-a4c5-46997ac7f3bd" rule id: "rule-1" signals index: ".siem-signals"' ); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts index a0d5c833b208c..1d2b1c23f868f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -119,6 +119,8 @@ export const signalRulesAlertType = ({ timestampOverride, type, exceptionsList, + concurrentSearches, + itemsPerSearch, } = params; const searchAfterSize = Math.min(maxSignals, DEFAULT_SEARCH_AFTER_PAGE_SIZE); @@ -360,7 +362,7 @@ export const signalRulesAlertType = ({ ) { throw new Error( [ - 'Threat Match rule is missing threatQuery and/or threatIndex and/or threatMapping:', + 'Indicator match is missing threatQuery and/or threatIndex and/or threatMapping:', `threatQuery: "${threatQuery}"`, `threatIndex: "${threatIndex}"`, `threatMapping: "${threatMapping}"`, @@ -403,6 +405,8 @@ export const signalRulesAlertType = ({ threatLanguage, buildRuleMessage, threatIndex, + concurrentSearches: concurrentSearches ?? 1, + itemsPerSearch: itemsPerSearch ?? 9000, }); } else if (type === 'query' || type === 'saved_query') { const inputIndex = await getInputIndex(services, version, index); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts index 85d172b3631a9..8eed838fc9680 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts @@ -19,28 +19,32 @@ import { } from './build_threat_mapping_filter'; import { getThreatMappingMock, - getThreatListSearchResponseMock, getThreatListItemMock, getThreatMappingFilterMock, getFilterThreatMapping, getThreatMappingFiltersShouldMock, getThreatMappingFilterShouldMock, + getThreatListSearchResponseMock, } from './build_threat_mapping_filter.mock'; -import { BooleanFilter } from './types'; +import { BooleanFilter, ThreatListItem } from './types'; describe('build_threat_mapping_filter', () => { describe('buildThreatMappingFilter', () => { test('it should throw if given a chunk over 1024 in size', () => { const threatMapping = getThreatMappingMock(); - const threatList = getThreatListSearchResponseMock(); + const threatList = getThreatListSearchResponseMock().hits.hits; expect(() => - buildThreatMappingFilter({ threatMapping, threatList, chunkSize: 1025 }) + buildThreatMappingFilter({ + threatMapping, + threatList, + chunkSize: 1025, + }) ).toThrow('chunk sizes cannot exceed 1024 in size'); }); test('it should NOT throw if given a chunk under 1024 in size', () => { const threatMapping = getThreatMappingMock(); - const threatList = getThreatListSearchResponseMock(); + const threatList = getThreatListSearchResponseMock().hits.hits; expect(() => buildThreatMappingFilter({ threatMapping, threatList, chunkSize: 1023 }) ).not.toThrow(); @@ -48,30 +52,30 @@ describe('build_threat_mapping_filter', () => { test('it should create the correct entries when using the default mocks', () => { const threatMapping = getThreatMappingMock(); - const threatList = getThreatListSearchResponseMock(); + const threatList = getThreatListSearchResponseMock().hits.hits; const filter = buildThreatMappingFilter({ threatMapping, threatList }); expect(filter).toEqual(getThreatMappingFilterMock()); }); test('it should not mutate the original threatMapping', () => { const threatMapping = getThreatMappingMock(); - const threatList = getThreatListSearchResponseMock(); + const threatList = getThreatListSearchResponseMock().hits.hits; buildThreatMappingFilter({ threatMapping, threatList }); expect(threatMapping).toEqual(getThreatMappingMock()); }); test('it should not mutate the original threatListItem', () => { const threatMapping = getThreatMappingMock(); - const threatList = getThreatListSearchResponseMock(); + const threatList = getThreatListSearchResponseMock().hits.hits; buildThreatMappingFilter({ threatMapping, threatList }); - expect(threatList).toEqual(getThreatListSearchResponseMock()); + expect(threatList).toEqual(getThreatListSearchResponseMock().hits.hits); }); }); describe('filterThreatMapping', () => { test('it should not remove any entries when using the default mocks', () => { const threatMapping = getThreatMappingMock(); - const threatListItem = getThreatListItemMock(); + const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; const item = filterThreatMapping({ threatMapping, threatListItem }); const expected = getFilterThreatMapping(); @@ -80,7 +84,7 @@ describe('build_threat_mapping_filter', () => { test('it should only give one filtered element if only 1 element is defined', () => { const [firstElement] = getThreatMappingMock(); // get only the first element - const threatListItem = getThreatListItemMock(); + const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; const item = filterThreatMapping({ threatMapping: [firstElement], threatListItem }); const [firstElementFilter] = getFilterThreatMapping(); // get only the first element to compare @@ -89,7 +93,7 @@ describe('build_threat_mapping_filter', () => { test('it should not mutate the original threatMapping', () => { const threatMapping = getThreatMappingMock(); - const threatListItem = getThreatListItemMock(); + const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; filterThreatMapping({ threatMapping, @@ -100,13 +104,13 @@ describe('build_threat_mapping_filter', () => { test('it should not mutate the original threatListItem', () => { const threatMapping = getThreatMappingMock(); - const threatListItem = getThreatListItemMock(); + const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; filterThreatMapping({ threatMapping, threatListItem, }); - expect(threatListItem).toEqual(getThreatListItemMock()); + expect(threatListItem).toEqual(getThreatListSearchResponseMock().hits.hits[0]); }); test('it should remove the entire "AND" clause if one of the pieces of data is missing from the list', () => { @@ -166,9 +170,11 @@ describe('build_threat_mapping_filter', () => { }, ], threatListItem: { - '@timestamp': '2020-09-09T21:59:13Z', - host: { - name: 'host-1', + _source: { + '@timestamp': '2020-09-09T21:59:13Z', + host: { + name: 'host-1', + }, }, }, }); @@ -189,7 +195,7 @@ describe('build_threat_mapping_filter', () => { describe('createInnerAndClauses', () => { test('it should return two clauses given a single entry', () => { const [{ entries: threatMappingEntries }] = getThreatMappingMock(); // get the first element - const threatListItem = getThreatListItemMock(); + const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; const innerClause = createInnerAndClauses({ threatMappingEntries, threatListItem }); const { bool: { @@ -219,7 +225,7 @@ describe('build_threat_mapping_filter', () => { type: 'mapping', }, ]; - const threatListItem = getThreatListItemMock(); + const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; const innerClause = createInnerAndClauses({ threatMappingEntries, threatListItem }); const { bool: { @@ -248,7 +254,7 @@ describe('build_threat_mapping_filter', () => { type: 'mapping', }, ]; - const threatListItem = getThreatListItemMock(); + const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; const innerClause = createInnerAndClauses({ threatMappingEntries, threatListItem }); const { bool: { @@ -275,7 +281,7 @@ describe('build_threat_mapping_filter', () => { type: 'mapping', }, ]; - const threatListItem = getThreatListItemMock(); + const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; const innerClause = createInnerAndClauses({ threatMappingEntries, threatListItem }); expect(innerClause).toEqual([]); }); @@ -284,27 +290,31 @@ describe('build_threat_mapping_filter', () => { describe('createAndOrClauses', () => { test('it should return all clauses given the entries', () => { const threatMapping = getThreatMappingMock(); - const threatListItem = getThreatListItemMock(); + const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; const innerClause = createAndOrClauses({ threatMapping, threatListItem }); expect(innerClause).toEqual(getThreatMappingFilterShouldMock()); }); test('it should filter out data from entries that do not have mappings', () => { const threatMapping = getThreatMappingMock(); - const threatListItem = { ...getThreatListItemMock(), foo: 'bar' }; + const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; + threatListItem._source = { + ...getThreatListSearchResponseMock().hits.hits[0]._source, + foo: 'bar', + }; const innerClause = createAndOrClauses({ threatMapping, threatListItem }); expect(innerClause).toEqual(getThreatMappingFilterShouldMock()); }); test('it should return an empty boolean given an empty array', () => { - const threatListItem = getThreatListItemMock(); + const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; const innerClause = createAndOrClauses({ threatMapping: [], threatListItem }); expect(innerClause).toEqual({ bool: { minimum_should_match: 1, should: [] } }); }); test('it should return an empty boolean clause given an empty object for a threat list item', () => { const threatMapping = getThreatMappingMock(); - const innerClause = createAndOrClauses({ threatMapping, threatListItem: {} }); + const innerClause = createAndOrClauses({ threatMapping, threatListItem: { _source: {} } }); expect(innerClause).toEqual({ bool: { minimum_should_match: 1, should: [] } }); }); }); @@ -312,7 +322,7 @@ describe('build_threat_mapping_filter', () => { describe('buildEntriesMappingFilter', () => { test('it should return all clauses given the entries', () => { const threatMapping = getThreatMappingMock(); - const threatList = getThreatListSearchResponseMock(); + const threatList = getThreatListSearchResponseMock().hits.hits; const mapping = buildEntriesMappingFilter({ threatMapping, threatList, @@ -326,8 +336,7 @@ describe('build_threat_mapping_filter', () => { test('it should return empty "should" given an empty threat list', () => { const threatMapping = getThreatMappingMock(); - const threatList = getThreatListSearchResponseMock(); - threatList.hits.hits = []; + const threatList: ThreatListItem[] = []; const mapping = buildEntriesMappingFilter({ threatMapping, threatList, @@ -340,7 +349,7 @@ describe('build_threat_mapping_filter', () => { }); test('it should return empty "should" given an empty threat mapping', () => { - const threatList = getThreatListSearchResponseMock(); + const threatList = getThreatListSearchResponseMock().hits.hits; const mapping = buildEntriesMappingFilter({ threatMapping: [], threatList, @@ -374,7 +383,7 @@ describe('build_threat_mapping_filter', () => { }, ], ]; - const threatList = getThreatListSearchResponseMock(); + const threatList = getThreatListSearchResponseMock().hits.hits; const mapping = buildEntriesMappingFilter({ threatMapping, threatList, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts index 346f156a9ec33..294d97e0bf2f1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts @@ -53,9 +53,9 @@ export const filterThreatMapping = ({ }: FilterThreatMappingOptions): ThreatMapping => threatMapping .map((threatMap) => { - const atLeastOneItemMissingInThreatList = threatMap.entries.some( - (entry) => get(entry.value, threatListItem) == null - ); + const atLeastOneItemMissingInThreatList = threatMap.entries.some((entry) => { + return get(entry.value, threatListItem._source) == null; + }); if (atLeastOneItemMissingInThreatList) { return { ...threatMap, entries: [] }; } else { @@ -69,7 +69,7 @@ export const createInnerAndClauses = ({ threatListItem, }: CreateInnerAndClausesOptions): BooleanFilter[] => { return threatMappingEntries.reduce((accum, threatMappingEntry) => { - const value = get(threatMappingEntry.value, threatListItem); + const value = get(threatMappingEntry.value, threatListItem._source); if (value != null) { // These values could be potentially 10k+ large so mutating the array intentionally accum.push({ @@ -114,24 +114,21 @@ export const buildEntriesMappingFilter = ({ threatList, chunkSize, }: BuildEntriesMappingFilterOptions): BooleanFilter => { - const combinedShould = threatList.hits.hits.reduce( - (accum, threatListSearchItem) => { - const filteredEntries = filterThreatMapping({ - threatMapping, - threatListItem: threatListSearchItem._source, - }); - const queryWithAndOrClause = createAndOrClauses({ - threatMapping: filteredEntries, - threatListItem: threatListSearchItem._source, - }); - if (queryWithAndOrClause.bool.should.length !== 0) { - // These values can be 10k+ large, so using a push here for performance - accum.push(queryWithAndOrClause); - } - return accum; - }, - [] - ); + const combinedShould = threatList.reduce((accum, threatListSearchItem) => { + const filteredEntries = filterThreatMapping({ + threatMapping, + threatListItem: threatListSearchItem, + }); + const queryWithAndOrClause = createAndOrClauses({ + threatMapping: filteredEntries, + threatListItem: threatListSearchItem, + }); + if (queryWithAndOrClause.bool.should.length !== 0) { + // These values can be 10k+ large, so using a push here for performance + accum.push(queryWithAndOrClause); + } + return accum; + }, []); const should = splitShouldClauses({ should: combinedShould, chunkSize }); return { bool: { should, minimum_should_match: 1 } }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts index 037f91240edfa..43fb759d07620 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts @@ -4,13 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getThreatList } from './get_threat_list'; import { buildThreatMappingFilter } from './build_threat_mapping_filter'; import { getFilter } from '../get_filter'; import { searchAfterAndBulkCreate } from '../search_after_bulk_create'; -import { CreateThreatSignalOptions, ThreatSignalResults } from './types'; -import { combineResults } from './utils'; +import { CreateThreatSignalOptions } from './types'; +import { SearchAfterAndBulkCreateReturnType } from '../types'; export const createThreatSignal = async ({ threatMapping, @@ -41,28 +40,11 @@ export const createThreatSignal = async ({ refresh, tags, throttle, - threatFilters, - threatQuery, - threatLanguage, buildRuleMessage, - threatIndex, name, currentThreatList, currentResult, -}: CreateThreatSignalOptions): Promise => { - const threatList = await getThreatList({ - callCluster: services.callCluster, - exceptionItems, - query: threatQuery, - language: threatLanguage, - threatFilters, - index: threatIndex, - searchAfter: currentThreatList.hits.hits[currentThreatList.hits.hits.length - 1].sort, - sortField: undefined, - sortOrder: undefined, - listClient, - }); - +}: CreateThreatSignalOptions): Promise => { const threatFilter = buildThreatMappingFilter({ threatMapping, threatList: currentThreatList, @@ -71,7 +53,12 @@ export const createThreatSignal = async ({ if (threatFilter.query.bool.should.length === 0) { // empty threat list and we do not want to return everything as being // a hit so opt to return the existing result. - return { threatList, results: currentResult }; + logger.debug( + buildRuleMessage( + 'Indicator items are empty after filtering for missing data, returning without attempting a match' + ) + ); + return currentResult; } else { const esFilter = await getFilter({ type, @@ -83,7 +70,13 @@ export const createThreatSignal = async ({ index: inputIndex, lists: exceptionItems, }); - const newResult = await searchAfterAndBulkCreate({ + + logger.debug( + buildRuleMessage( + `${threatFilter.query.bool.should.length} indicator items are being checked for existence of matches` + ) + ); + const result = await searchAfterAndBulkCreate({ gap, previousStartedAt, listClient, @@ -110,7 +103,15 @@ export const createThreatSignal = async ({ throttle, buildRuleMessage, }); - const results = combineResults(currentResult, newResult); - return { threatList, results }; + logger.debug( + buildRuleMessage( + `${ + threatFilter.query.bool.should.length + } items have completed match checks and the total times to search were ${ + result.searchAfterTimes.length !== 0 ? result.searchAfterTimes : '(unknown) ' + }ms` + ) + ); + return result; } }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts index 8be76dc8caf0f..e90c45d40de95 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts @@ -4,11 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getThreatList } from './get_threat_list'; +import chunk from 'lodash/fp/chunk'; +import { getThreatList, getThreatListCount } from './get_threat_list'; import { CreateThreatSignalsOptions } from './types'; import { createThreatSignal } from './create_threat_signal'; import { SearchAfterAndBulkCreateReturnType } from '../types'; +import { combineConcurrentResults } from './utils'; export const createThreatSignals = async ({ threatMapping, @@ -45,7 +47,12 @@ export const createThreatSignals = async ({ buildRuleMessage, threatIndex, name, + concurrentSearches, + itemsPerSearch, }: CreateThreatSignalsOptions): Promise => { + logger.debug(buildRuleMessage('Indicator matching rule starting')); + const perPage = concurrentSearches * itemsPerSearch; + let results: SearchAfterAndBulkCreateReturnType = { success: true, bulkCreateTimes: [], @@ -55,6 +62,16 @@ export const createThreatSignals = async ({ errors: [], }; + let threatListCount = await getThreatListCount({ + callCluster: services.callCluster, + exceptionItems, + threatFilters, + query: threatQuery, + language: threatLanguage, + index: threatIndex, + }); + logger.debug(buildRuleMessage(`Total indicator items: ${threatListCount}`)); + let threatList = await getThreatList({ callCluster: services.callCluster, exceptionItems, @@ -66,47 +83,89 @@ export const createThreatSignals = async ({ searchAfter: undefined, sortField: undefined, sortOrder: undefined, + logger, + buildRuleMessage, + perPage, }); - while (threatList.hits.hits.length !== 0 && results.createdSignalsCount <= params.maxSignals) { - ({ threatList, results } = await createThreatSignal({ - threatMapping, - query, - inputIndex, - type, - filters, - language, - savedId, - services, + while (threatList.hits.hits.length !== 0) { + const chunks = chunk(itemsPerSearch, threatList.hits.hits); + logger.debug(buildRuleMessage(`${chunks.length} concurrent indicator searches are starting.`)); + const concurrentSearchesPerformed = chunks.map>( + (slicedChunk) => + createThreatSignal({ + threatMapping, + query, + inputIndex, + type, + filters, + language, + savedId, + services, + exceptionItems, + gap, + previousStartedAt, + listClient, + logger, + eventsTelemetry, + alertId, + outputIndex, + params, + searchAfterSize, + actions, + createdBy, + createdAt, + updatedBy, + updatedAt, + interval, + enabled, + tags, + refresh, + throttle, + buildRuleMessage, + name, + currentThreatList: slicedChunk, + currentResult: results, + }) + ); + const searchesPerformed = await Promise.all(concurrentSearchesPerformed); + results = combineConcurrentResults(results, searchesPerformed); + threatListCount -= threatList.hits.hits.length; + logger.debug( + buildRuleMessage( + `Concurrent indicator match searches completed with ${results.createdSignalsCount} signals found`, + `search times of ${results.searchAfterTimes}ms,`, + `bulk create times ${results.bulkCreateTimes}ms,`, + `all successes are ${results.success}` + ) + ); + if (results.createdSignalsCount >= params.maxSignals) { + logger.debug( + buildRuleMessage( + `Indicator match has reached its max signals count ${params.maxSignals}. Additional indicator items not checked are ${threatListCount}` + ) + ); + break; + } + logger.debug(buildRuleMessage(`Indicator items left to check are ${threatListCount}`)); + + threatList = await getThreatList({ + callCluster: services.callCluster, exceptionItems, - gap, - previousStartedAt, - listClient, - logger, - eventsTelemetry, - alertId, - outputIndex, - params, - searchAfterSize, - actions, - createdBy, - createdAt, - updatedBy, - updatedAt, - interval, - enabled, - tags, - refresh, - throttle, + query: threatQuery, + language: threatLanguage, threatFilters, - threatQuery, + index: threatIndex, + searchAfter: threatList.hits.hits[threatList.hits.hits.length - 1].sort, + sortField: undefined, + sortOrder: undefined, + listClient, buildRuleMessage, - threatIndex, - threatLanguage, - name, - currentThreatList: threatList, - currentResult: results, - })); + logger, + perPage, + }); } + + logger.debug(buildRuleMessage('Indicator matching rule has completed')); return results; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts index 3147eb1705168..aba3f6f69d706 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts @@ -10,6 +10,7 @@ import { GetSortWithTieBreakerOptions, GetThreatListOptions, SortWithTieBreaker, + ThreatListCountOptions, ThreatListItem, } from './types'; @@ -30,6 +31,8 @@ export const getThreatList = async ({ exceptionItems, threatFilters, listClient, + buildRuleMessage, + logger, }: GetThreatListOptions): Promise> => { const calculatedPerPage = perPage ?? MAX_PER_PAGE; if (calculatedPerPage > 10000) { @@ -43,6 +46,11 @@ export const getThreatList = async ({ exceptionItems ); + logger.debug( + buildRuleMessage( + `Querying the indicator items from the index: "${index}" with searchAfter: "${searchAfter}" for up to ${calculatedPerPage} indicator items` + ) + ); const response: SearchResponse = await callCluster('search', { body: { query: queryFilter, @@ -58,6 +66,8 @@ export const getThreatList = async ({ index, size: calculatedPerPage, }); + + logger.debug(buildRuleMessage(`Retrieved indicator items of size: ${response.hits.hits.length}`)); return response; }; @@ -89,3 +99,30 @@ export const getSortWithTieBreaker = ({ } } }; + +export const getThreatListCount = async ({ + callCluster, + query, + language, + threatFilters, + index, + exceptionItems, +}: ThreatListCountOptions): Promise => { + const queryFilter = getQueryFilter( + query, + language ?? 'kuery', + threatFilters, + index, + exceptionItems + ); + const response: { + count: number; + } = await callCluster('count', { + body: { + query: queryFilter, + }, + ignoreUnavailable: true, + index, + }); + return response.count; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts index 0078cf1b3c64f..2e32a4e682403 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts @@ -5,7 +5,6 @@ */ import { Duration } from 'moment'; -import { SearchResponse } from 'elasticsearch'; import { ListClient } from '../../../../../../lists/server'; import { Type, @@ -17,6 +16,8 @@ import { ThreatMappingEntries, ThreatIndex, ThreatLanguageOrUndefined, + ConcurrentSearches, + ItemsPerSearch, } from '../../../../../common/detection_engine/schemas/types/threat_mapping'; import { PartialFilter, RuleTypeParams } from '../../types'; import { AlertServices } from '../../../../../../alerts/server'; @@ -62,6 +63,8 @@ export interface CreateThreatSignalsOptions { threatIndex: ThreatIndex; threatLanguage: ThreatLanguageOrUndefined; name: string; + concurrentSearches: ConcurrentSearches; + itemsPerSearch: ItemsPerSearch; } export interface CreateThreatSignalOptions { @@ -93,24 +96,15 @@ export interface CreateThreatSignalOptions { tags: string[]; refresh: false | 'wait_for'; throttle: string; - threatFilters: PartialFilter[]; - threatQuery: ThreatQuery; buildRuleMessage: BuildRuleMessage; - threatIndex: ThreatIndex; - threatLanguage: ThreatLanguageOrUndefined; name: string; - currentThreatList: SearchResponse; + currentThreatList: ThreatListItem[]; currentResult: SearchAfterAndBulkCreateReturnType; } -export interface ThreatSignalResults { - threatList: SearchResponse; - results: SearchAfterAndBulkCreateReturnType; -} - export interface BuildThreatMappingFilterOptions { threatMapping: ThreatMapping; - threatList: SearchResponse; + threatList: ThreatListItem[]; chunkSize?: number; } @@ -131,7 +125,7 @@ export interface CreateAndOrClausesOptions { export interface BuildEntriesMappingFilterOptions { threatMapping: ThreatMapping; - threatList: SearchResponse; + threatList: ThreatListItem[]; chunkSize: number; } @@ -156,6 +150,17 @@ export interface GetThreatListOptions { threatFilters: PartialFilter[]; exceptionItems: ExceptionListItemSchema[]; listClient: ListClient; + buildRuleMessage: BuildRuleMessage; + logger: Logger; +} + +export interface ThreatListCountOptions { + callCluster: ILegacyScopedClusterClient['callAsCurrentUser']; + query: string; + language: ThreatLanguageOrUndefined; + threatFilters: PartialFilter[]; + index: string[]; + exceptionItems: ExceptionListItemSchema[]; } export interface GetSortWithTieBreakerOptions { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.test.ts index 27593b40b0c8f..840d64381c793 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.test.ts @@ -6,7 +6,13 @@ import { SearchAfterAndBulkCreateReturnType } from '../types'; -import { calculateAdditiveMax, combineResults } from './utils'; +import { + calculateAdditiveMax, + calculateMax, + calculateMaxLookBack, + combineConcurrentResults, + combineResults, +} from './utils'; describe('utils', () => { describe('calculateAdditiveMax', () => { @@ -156,4 +162,383 @@ describe('utils', () => { ); }); }); + + describe('calculateMax', () => { + test('it should return 0 for two empty arrays', () => { + const max = calculateMax([], []); + expect(max).toEqual('0'); + }); + + test('it should return 5 for two arrays with the numbers 5', () => { + const max = calculateMax(['5'], ['5']); + expect(max).toEqual('5'); + }); + + test('it should return 5 for two arrays with second array having just 5', () => { + const max = calculateMax([], ['5']); + expect(max).toEqual('5'); + }); + + test('it should return 5 for two arrays with first array having just 5', () => { + const max = calculateMax(['5'], []); + expect(max).toEqual('5'); + }); + + test('it should return 10 for the max of the two arrays when the max of each array is 10', () => { + const max = calculateMax(['3', '5', '1'], ['3', '5', '10']); + expect(max).toEqual('10'); + }); + + test('it should return 10 for the max of the two arrays when the max of the first is 10', () => { + const max = calculateMax(['3', '5', '10'], ['3', '5', '1']); + expect(max).toEqual('10'); + }); + }); + + describe('calculateMaxLookBack', () => { + test('it should return null if both are null', () => { + const max = calculateMaxLookBack(null, null); + expect(max).toEqual(null); + }); + + test('it should return undefined if both are undefined', () => { + const max = calculateMaxLookBack(undefined, undefined); + expect(max).toEqual(undefined); + }); + + test('it should return null if both one is null and other other is undefined', () => { + const max = calculateMaxLookBack(undefined, null); + expect(max).toEqual(null); + }); + + test('it should return null if both one is null and other other is undefined with flipped arguments', () => { + const max = calculateMaxLookBack(null, undefined); + expect(max).toEqual(null); + }); + + test('it should return a date time if one argument is null', () => { + const max = calculateMaxLookBack(null, new Date('2020-09-16T03:34:32.390Z')); + expect(max).toEqual(new Date('2020-09-16T03:34:32.390Z')); + }); + + test('it should return a date time if one argument is null with flipped arguments', () => { + const max = calculateMaxLookBack(new Date('2020-09-16T03:34:32.390Z'), null); + expect(max).toEqual(new Date('2020-09-16T03:34:32.390Z')); + }); + + test('it should return a date time if one argument is undefined', () => { + const max = calculateMaxLookBack(new Date('2020-09-16T03:34:32.390Z'), undefined); + expect(max).toEqual(new Date('2020-09-16T03:34:32.390Z')); + }); + + test('it should return a date time if one argument is undefined with flipped arguments', () => { + const max = calculateMaxLookBack(undefined, new Date('2020-09-16T03:34:32.390Z')); + expect(max).toEqual(new Date('2020-09-16T03:34:32.390Z')); + }); + + test('it should return a date time that is larger than the other', () => { + const max = calculateMaxLookBack( + new Date('2020-10-16T03:34:32.390Z'), + new Date('2020-09-16T03:34:32.390Z') + ); + expect(max).toEqual(new Date('2020-10-16T03:34:32.390Z')); + }); + + test('it should return a date time that is larger than the other with arguments flipped', () => { + const max = calculateMaxLookBack( + new Date('2020-09-16T03:34:32.390Z'), + new Date('2020-10-16T03:34:32.390Z') + ); + expect(max).toEqual(new Date('2020-10-16T03:34:32.390Z')); + }); + }); + + describe('combineConcurrentResults', () => { + test('it should use the maximum found if given an empty array for newResults', () => { + const existingResult: SearchAfterAndBulkCreateReturnType = { + success: true, + searchAfterTimes: ['10', '20', '30'], + bulkCreateTimes: ['5', '15', '25'], + lastLookBackDate: undefined, + createdSignalsCount: 3, + errors: [], + }; + const expectedResult: SearchAfterAndBulkCreateReturnType = { + success: true, + searchAfterTimes: ['30'], // max value from existingResult.searchAfterTimes + bulkCreateTimes: ['25'], // max value from existingResult.bulkCreateTimes + lastLookBackDate: undefined, + createdSignalsCount: 3, + errors: [], + }; + const combinedResults = combineConcurrentResults(existingResult, []); + expect(combinedResults).toEqual(expectedResult); + }); + + test('it should work with empty arrays for searchAfterTimes and bulkCreateTimes', () => { + const existingResult: SearchAfterAndBulkCreateReturnType = { + success: true, + searchAfterTimes: ['10', '20', '30'], + bulkCreateTimes: ['5', '15', '25'], + lastLookBackDate: undefined, + createdSignalsCount: 3, + errors: [], + }; + const newResult: SearchAfterAndBulkCreateReturnType = { + success: true, + searchAfterTimes: [], + bulkCreateTimes: [], + lastLookBackDate: undefined, + createdSignalsCount: 0, + errors: [], + }; + const expectedResult: SearchAfterAndBulkCreateReturnType = { + success: true, + searchAfterTimes: ['30'], // max value from existingResult.searchAfterTimes + bulkCreateTimes: ['25'], // max value from existingResult.bulkCreateTimes + lastLookBackDate: undefined, + createdSignalsCount: 3, + errors: [], + }; + + const combinedResults = combineConcurrentResults(existingResult, [newResult]); + expect(combinedResults).toEqual(expectedResult); + }); + + test('it should get the max of two new results and then combine the result with an existingResult correctly', () => { + const existingResult: SearchAfterAndBulkCreateReturnType = { + success: true, + searchAfterTimes: ['10', '20', '30'], // max is 30 + bulkCreateTimes: ['5', '15', '25'], // max is 25 + lastLookBackDate: undefined, + createdSignalsCount: 3, + errors: [], + }; + const newResult1: SearchAfterAndBulkCreateReturnType = { + success: true, + searchAfterTimes: ['10', '20', '30'], + bulkCreateTimes: ['5', '15', '25'], + lastLookBackDate: new Date('2020-09-16T03:34:32.390Z'), + createdSignalsCount: 5, + errors: [], + }; + const newResult2: SearchAfterAndBulkCreateReturnType = { + success: true, + searchAfterTimes: ['40', '5', '15'], + bulkCreateTimes: ['50', '5', '15'], + lastLookBackDate: new Date('2020-09-16T04:34:32.390Z'), + createdSignalsCount: 8, + errors: [], + }; + + const expectedResult: SearchAfterAndBulkCreateReturnType = { + success: true, + searchAfterTimes: ['70'], // max value between newResult1 and newResult2 + max array value of existingResult (40 + 30 = 70) + bulkCreateTimes: ['75'], // max value between newResult1 and newResult2 + max array value of existingResult (50 + 25 = 75) + lastLookBackDate: new Date('2020-09-16T04:34:32.390Z'), // max lastLookBackDate + createdSignalsCount: 16, // all the signals counted together (8 + 5 + 3) + errors: [], + }; + + const combinedResults = combineConcurrentResults(existingResult, [newResult1, newResult2]); + expect(combinedResults).toEqual(expectedResult); + }); + + test('it should get the max of two new results and then combine the result with an existingResult correctly when the results are flipped around', () => { + const existingResult: SearchAfterAndBulkCreateReturnType = { + success: true, + searchAfterTimes: ['10', '20', '30'], // max is 30 + bulkCreateTimes: ['5', '15', '25'], // max is 25 + lastLookBackDate: undefined, + createdSignalsCount: 3, + errors: [], + }; + const newResult1: SearchAfterAndBulkCreateReturnType = { + success: true, + searchAfterTimes: ['10', '20', '30'], + bulkCreateTimes: ['5', '15', '25'], + lastLookBackDate: new Date('2020-09-16T03:34:32.390Z'), + createdSignalsCount: 5, + errors: [], + }; + const newResult2: SearchAfterAndBulkCreateReturnType = { + success: true, + searchAfterTimes: ['40', '5', '15'], + bulkCreateTimes: ['50', '5', '15'], + lastLookBackDate: new Date('2020-09-16T04:34:32.390Z'), + createdSignalsCount: 8, + errors: [], + }; + + const expectedResult: SearchAfterAndBulkCreateReturnType = { + success: true, + searchAfterTimes: ['70'], // max value between newResult1 and newResult2 + max array value of existingResult (40 + 30 = 70) + bulkCreateTimes: ['75'], // max value between newResult1 and newResult2 + max array value of existingResult (50 + 25 = 75) + lastLookBackDate: new Date('2020-09-16T04:34:32.390Z'), // max lastLookBackDate + createdSignalsCount: 16, // all the signals counted together (8 + 5 + 3) + errors: [], + }; + + const combinedResults = combineConcurrentResults(existingResult, [newResult2, newResult1]); // two array elements are flipped + expect(combinedResults).toEqual(expectedResult); + }); + + test('it should return the max date correctly if one date contains a null', () => { + const existingResult: SearchAfterAndBulkCreateReturnType = { + success: true, + searchAfterTimes: ['10', '20', '30'], // max is 30 + bulkCreateTimes: ['5', '15', '25'], // max is 25 + lastLookBackDate: undefined, + createdSignalsCount: 3, + errors: [], + }; + const newResult1: SearchAfterAndBulkCreateReturnType = { + success: true, + searchAfterTimes: ['10', '20', '30'], + bulkCreateTimes: ['5', '15', '25'], + lastLookBackDate: new Date('2020-09-16T03:34:32.390Z'), + createdSignalsCount: 5, + errors: [], + }; + const newResult2: SearchAfterAndBulkCreateReturnType = { + success: true, + searchAfterTimes: ['40', '5', '15'], + bulkCreateTimes: ['50', '5', '15'], + lastLookBackDate: null, + createdSignalsCount: 8, + errors: [], + }; + + const expectedResult: SearchAfterAndBulkCreateReturnType = { + success: true, + searchAfterTimes: ['70'], // max value between newResult1 and newResult2 + max array value of existingResult (40 + 30 = 70) + bulkCreateTimes: ['75'], // max value between newResult1 and newResult2 + max array value of existingResult (50 + 25 = 75) + lastLookBackDate: new Date('2020-09-16T03:34:32.390Z'), // max lastLookBackDate + createdSignalsCount: 16, // all the signals counted together (8 + 5 + 3) + errors: [], + }; + + const combinedResults = combineConcurrentResults(existingResult, [newResult1, newResult2]); + expect(combinedResults).toEqual(expectedResult); + }); + + test('it should combine two results with success set to "true" if both are "true"', () => { + const existingResult: SearchAfterAndBulkCreateReturnType = { + success: true, + searchAfterTimes: ['10', '20', '30'], + bulkCreateTimes: ['5', '15', '25'], + lastLookBackDate: undefined, + createdSignalsCount: 3, + errors: [], + }; + + const newResult: SearchAfterAndBulkCreateReturnType = { + success: true, + searchAfterTimes: ['10', '20', '30'], + bulkCreateTimes: ['5', '15', '25'], + lastLookBackDate: undefined, + createdSignalsCount: 3, + errors: [], + }; + const combinedResults = combineConcurrentResults(existingResult, [newResult]); + expect(combinedResults.success).toEqual(true); + }); + + test('it should combine two results with success set to "false" if one of them is "false"', () => { + const existingResult: SearchAfterAndBulkCreateReturnType = { + success: false, + searchAfterTimes: ['10', '20', '30'], + bulkCreateTimes: ['5', '15', '25'], + lastLookBackDate: undefined, + createdSignalsCount: 3, + errors: [], + }; + + const newResult: SearchAfterAndBulkCreateReturnType = { + success: true, + searchAfterTimes: ['10', '20', '30'], + bulkCreateTimes: ['5', '15', '25'], + lastLookBackDate: undefined, + createdSignalsCount: 3, + errors: [], + }; + const combinedResults = combineConcurrentResults(existingResult, [newResult]); + expect(combinedResults.success).toEqual(false); + }); + + test('it should use the latest date if it is set in the new result', () => { + const existingResult: SearchAfterAndBulkCreateReturnType = { + success: false, + searchAfterTimes: ['10', '20', '30'], + bulkCreateTimes: ['5', '15', '25'], + lastLookBackDate: undefined, + createdSignalsCount: 3, + errors: [], + }; + + const newResult: SearchAfterAndBulkCreateReturnType = { + success: true, + searchAfterTimes: ['10', '20', '30'], + bulkCreateTimes: ['5', '15', '25'], + lastLookBackDate: new Date('2020-09-16T03:34:32.390Z'), + createdSignalsCount: 3, + errors: [], + }; + const combinedResults = combineConcurrentResults(existingResult, [newResult]); + expect(combinedResults.lastLookBackDate?.toISOString()).toEqual('2020-09-16T03:34:32.390Z'); + }); + + test('it should combine the searchAfterTimes and the bulkCreateTimes', () => { + const existingResult: SearchAfterAndBulkCreateReturnType = { + success: false, + searchAfterTimes: ['10', '20', '30'], + bulkCreateTimes: ['5', '15', '25'], + lastLookBackDate: undefined, + createdSignalsCount: 3, + errors: [], + }; + + const newResult: SearchAfterAndBulkCreateReturnType = { + success: true, + searchAfterTimes: ['10', '20', '30'], + bulkCreateTimes: ['5', '15', '25'], + lastLookBackDate: new Date('2020-09-16T03:34:32.390Z'), + createdSignalsCount: 3, + errors: [], + }; + const combinedResults = combineConcurrentResults(existingResult, [newResult]); + expect(combinedResults).toEqual( + expect.objectContaining({ + searchAfterTimes: ['60'], + bulkCreateTimes: ['50'], + }) + ); + }); + + test('it should combine errors together without duplicates', () => { + const existingResult: SearchAfterAndBulkCreateReturnType = { + success: false, + searchAfterTimes: ['10', '20', '30'], + bulkCreateTimes: ['5', '15', '25'], + lastLookBackDate: undefined, + createdSignalsCount: 3, + errors: ['error 1', 'error 2', 'error 3'], + }; + + const newResult: SearchAfterAndBulkCreateReturnType = { + success: true, + searchAfterTimes: ['10', '20', '30'], + bulkCreateTimes: ['5', '15', '25'], + lastLookBackDate: new Date('2020-09-16T03:34:32.390Z'), + createdSignalsCount: 3, + errors: ['error 4', 'error 1', 'error 3', 'error 5'], + }; + const combinedResults = combineConcurrentResults(existingResult, [newResult]); + expect(combinedResults).toEqual( + expect.objectContaining({ + errors: ['error 1', 'error 2', 'error 3', 'error 4', 'error 5'], + }) + ); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts index 401a4a1acb065..d6c91fad6d9cb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts @@ -19,6 +19,41 @@ export const calculateAdditiveMax = (existingTimers: string[], newTimers: string return [String(numericNewTimerMax + numericExistingTimerMax)]; }; +/** + * Given two timers this will take the max of each and then get the max from each. + * Max(Max(timer_array_1), Max(timer_array_2)) + * @param existingTimers String array of existing timers + * @param newTimers String array of new timers. + * @returns String array of the new maximum between the two timers + */ +export const calculateMax = (existingTimers: string[], newTimers: string[]): string => { + const numericNewTimerMax = Math.max(0, ...newTimers.map((time) => +time)); + const numericExistingTimerMax = Math.max(0, ...existingTimers.map((time) => +time)); + return String(Math.max(numericNewTimerMax, numericExistingTimerMax)); +}; + +/** + * Given two dates this will return the larger of the two unless one of them is null + * or undefined. If both one or the other is null/undefined it will return the newDate. + * If there is a mix of "undefined" and "null", this will prefer to set it to "null" as having + * a higher value than "undefined" + * @param existingDate The existing date which can be undefined or null or a date + * @param newDate The new date which can be undefined or null or a date + */ +export const calculateMaxLookBack = ( + existingDate: Date | null | undefined, + newDate: Date | null | undefined +): Date | null | undefined => { + const newDateValue = newDate === null ? 1 : newDate === undefined ? 0 : newDate.valueOf(); + const existingDateValue = + existingDate === null ? 1 : existingDate === undefined ? 0 : existingDate.valueOf(); + if (newDateValue >= existingDateValue) { + return newDate; + } else { + return existingDate; + } +}; + /** * Combines two results together and returns the results combined * @param currentResult The current result to combine with a newResult @@ -38,3 +73,39 @@ export const combineResults = ( createdSignalsCount: currentResult.createdSignalsCount + newResult.createdSignalsCount, errors: [...new Set([...currentResult.errors, ...newResult.errors])], }); + +/** + * Combines two results together and returns the results combined + * @param currentResult The current result to combine with a newResult + * @param newResult The new result to combine + */ +export const combineConcurrentResults = ( + currentResult: SearchAfterAndBulkCreateReturnType, + newResult: SearchAfterAndBulkCreateReturnType[] +): SearchAfterAndBulkCreateReturnType => { + const maxedNewResult = newResult.reduce( + (accum, item) => { + const maxSearchAfterTime = calculateMax(accum.searchAfterTimes, item.searchAfterTimes); + const maxBulkCreateTimes = calculateMax(accum.bulkCreateTimes, item.bulkCreateTimes); + const lastLookBackDate = calculateMaxLookBack(accum.lastLookBackDate, item.lastLookBackDate); + return { + success: accum.success && item.success, + searchAfterTimes: [maxSearchAfterTime], + bulkCreateTimes: [maxBulkCreateTimes], + lastLookBackDate, + createdSignalsCount: accum.createdSignalsCount + item.createdSignalsCount, + errors: [...new Set([...accum.errors, ...item.errors])], + }; + }, + { + success: true, + searchAfterTimes: [], + bulkCreateTimes: [], + lastLookBackDate: undefined, + createdSignalsCount: 0, + errors: [], + } + ); + + return combineResults(currentResult, maxedNewResult); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts index cf4d989c1f4c8..5cac76e2b0c01 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts @@ -44,6 +44,8 @@ import { ThreatQueryOrUndefined, ThreatMappingOrUndefined, ThreatLanguageOrUndefined, + ConcurrentSearchesOrUndefined, + ItemsPerSearchOrUndefined, } from '../../../common/detection_engine/schemas/types/threat_mapping'; import { LegacyCallAPIOptions } from '../../../../../../src/core/server'; @@ -93,6 +95,8 @@ export interface RuleTypeParams { references: References; version: Version; exceptionsList: ListArrayOrUndefined; + concurrentSearches: ConcurrentSearchesOrUndefined; + itemsPerSearch: ItemsPerSearchOrUndefined; } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/x-pack/plugins/security_solution/server/ui_settings.ts b/x-pack/plugins/security_solution/server/ui_settings.ts index 4b5261edcdfd0..6b10a9909e19c 100644 --- a/x-pack/plugins/security_solution/server/ui_settings.ts +++ b/x-pack/plugins/security_solution/server/ui_settings.ts @@ -23,6 +23,10 @@ import { NEWS_FEED_URL_SETTING_DEFAULT, IP_REPUTATION_LINKS_SETTING, IP_REPUTATION_LINKS_SETTING_DEFAULT, + DEFAULT_RULES_TABLE_REFRESH_SETTING, + DEFAULT_RULE_REFRESH_INTERVAL_ON, + DEFAULT_RULE_REFRESH_INTERVAL_VALUE, + DEFAULT_RULE_REFRESH_IDLE_VALUE, } from '../common/constants'; export const initUiSettings = (uiSettings: CoreSetup['uiSettings']) => { @@ -112,6 +116,31 @@ export const initUiSettings = (uiSettings: CoreSetup['uiSettings']) => { requiresPageReload: true, schema: schema.boolean(), }, + [DEFAULT_RULES_TABLE_REFRESH_SETTING]: { + name: i18n.translate('xpack.securitySolution.uiSettings.rulesTableRefresh', { + defaultMessage: 'Rules auto refresh', + }), + description: i18n.translate( + 'xpack.securitySolution.uiSettings.rulesTableRefreshDescription', + { + defaultMessage: + '

Enables auto refresh on the all rules and monitoring tables, in milliseconds

', + } + ), + type: 'json', + value: `{ + "on": ${DEFAULT_RULE_REFRESH_INTERVAL_ON}, + "value": ${DEFAULT_RULE_REFRESH_INTERVAL_VALUE}, + "idleTimeout": ${DEFAULT_RULE_REFRESH_IDLE_VALUE} +}`, + category: ['securitySolution'], + requiresPageReload: true, + schema: schema.object({ + idleTimeout: schema.number({ min: 300000 }), + value: schema.number({ min: 60000 }), + on: schema.boolean(), + }), + }, [NEWS_FEED_URL_SETTING]: { name: i18n.translate('xpack.securitySolution.uiSettings.newsFeedUrl', { defaultMessage: 'News feed URL', diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.test.ts index 3f5addb77cb33..48847686828a9 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.test.ts @@ -25,6 +25,7 @@ describe('ActionContext', () => { date: '2020-01-01T00:00:00.000Z', group: '[group]', value: 42, + function: 'count > 4', }; const context = addMessages({ name: '[alert-name]' }, base, params); expect(context.title).toMatchInlineSnapshot( @@ -53,6 +54,7 @@ describe('ActionContext', () => { date: '2020-01-01T00:00:00.000Z', group: '[group]', value: 42, + function: 'avg([aggField]) > 4.2', }; const context = addMessages({ name: '[alert-name]' }, base, params); expect(context.title).toMatchInlineSnapshot( @@ -80,6 +82,7 @@ describe('ActionContext', () => { date: '2020-01-01T00:00:00.000Z', group: '[group]', value: 4, + function: 'count between 4,5', }; const context = addMessages({ name: '[alert-name]' }, base, params); expect(context.title).toMatchInlineSnapshot( diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.ts index 5135e31e9322c..9bb0df9d07fd4 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.ts @@ -27,6 +27,8 @@ export interface BaseActionContext extends AlertInstanceContext { date: string; // the value that met the threshold value: number; + // the function that is used + function: string; } export function addMessages( @@ -42,9 +44,6 @@ export function addMessages( }, }); - const agg = params.aggField ? `${params.aggType}(${params.aggField})` : `${params.aggType}`; - const humanFn = `${agg} ${params.thresholdComparator} ${params.threshold.join(',')}`; - const window = `${params.timeWindowSize}${params.timeWindowUnit}`; const message = i18n.translate( 'xpack.stackAlerts.indexThreshold.alertTypeContextMessageDescription', @@ -55,7 +54,7 @@ export function addMessages( name: alertInfo.name, group: baseContext.group, value: baseContext.value, - function: humanFn, + function: baseContext.function, window, date: baseContext.date, }, diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.test.ts index 2f0cf3cbbcd16..d75f3af22ab06 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.test.ts @@ -46,6 +46,10 @@ describe('alertType', () => { "description": "The value that exceeded the threshold.", "name": "value", }, + Object { + "description": "A string describing the threshold comparator and threshold", + "name": "function", + }, ], "params": Array [ Object { diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts index 2a1ed429b7fe1..e0a9cd981dac0 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts @@ -83,6 +83,13 @@ export function getAlertType(service: Service): AlertType { return { @@ -107,6 +114,7 @@ export function getAlertType(service: Service): AlertType void; + setActionGroupIdByIndex?: (group: string, index: number) => void; setAlertProperty: (actions: AlertAction[]) => void; setActionParamsProperty: (key: string, value: any, index: number) => void; http: HttpSetup; - actionTypeRegistry: TypeRegistry; - toastNotifications: Pick< - ToastsApi, - 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' - >; + actionTypeRegistry: ActionTypeRegistryContract; + toastNotifications: ToastsSetup; + docLinks: DocLinksStart; actionTypes?: ActionType[]; messageVariables?: ActionVariable[]; defaultActionMessage?: string; - consumer: string; + capabilities: ApplicationStart['capabilities']; } ``` @@ -1339,17 +1339,20 @@ interface ActionAccordionFormProps { |Property|Description| |---|---| |actions|List of actions comes from alert.actions property.| -|defaultActionGroupId|Default action group id to which each new action will belong to.| +|defaultActionGroupId|Default action group id to which each new action will belong by default.| +|actionGroups|Optional. List of action groups to which new action can be assigned. The RunWhen field is only displayed when these action groups are specified| |setActionIdByIndex|Function for changing action 'id' by the proper index in alert.actions array.| +|setActionGroupIdByIndex|Function for changing action 'group' by the proper index in alert.actions array.| |setAlertProperty|Function for changing alert property 'actions'. Used when deleting action from the array to reset it.| |setActionParamsProperty|Function for changing action key/value property by index in alert.actions array.| |http|HttpSetup needed for executing API calls.| |actionTypeRegistry|Registry for action types.| -|toastNotifications|Toast messages.| +|toastNotifications|Toast messages Plugin Setup Contract.| +|docLinks|Documentation links Plugin Start Contract.| |actionTypes|Optional property, which allowes to define a list of available actions specific for a current plugin.| |actionTypes|Optional property, which allowes to define a list of variables for action 'message' property.| |defaultActionMessage|Optional property, which allowes to define a message value for action with 'message' property.| -|consumer|Name of the plugin that creates an action.| +|capabilities|Kibana core's Capabilities ApplicationStart['capabilities'].| AlertsContextProvider value options: diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/index.ts index d105e2c510bcd..a5b2fbb37e838 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/index.ts @@ -5,7 +5,6 @@ */ import { lazy } from 'react'; import { i18n } from '@kbn/i18n'; - import { AlertTypeModel } from '../../../../types'; import { validateExpression } from './validation'; import { IndexThresholdAlertParams } from './types'; @@ -26,6 +25,12 @@ export function getAlertType(): AlertTypeModel import('./expression')), validate: validateExpression, + defaultActionMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinAlertTypes.threshold.alertDefaultActionMessage', + { + defaultMessage: `alert \\{\\{alertName\\}\\} group \\{\\{context.group\\}\\} value \\{\\{context.value\\}\\} exceeded threshold \\{\\{context.function\\}\\} over \\{\\{params.timeWindowSize\\}\\}\\{\\{params.timeWindowUnit\\}\\} on \\{\\{context.date\\}\\}`, + } + ), requiresAppContext: false, }; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss index 24dbb865742d8..bb622829e997a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss @@ -3,9 +3,15 @@ } .actAccordionActionForm { - .euiCard { - box-shadow: none; - } + background-color: $euiColorLightestShade; +} + +.actAccordionActionForm .euiCard { + box-shadow: none; +} + +.actAccordionActionForm__button { + padding: $euiSizeM; } .actConnectorsListGrid { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx index 7c718e8248e41..94452e70e6bfa 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx @@ -6,7 +6,6 @@ import React, { Fragment, lazy } from 'react'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { coreMock } from '../../../../../../../src/core/public/mocks'; -import { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult, Alert, AlertAction } from '../../../types'; @@ -112,8 +111,6 @@ describe('action_form', () => { }; describe('action_form in alert', () => { - let wrapper: ReactWrapper; - async function setup(customActions?: AlertAction[]) { const { loadAllActions } = jest.requireMock('../../lib/action_connector_api'); loadAllActions.mockResolvedValueOnce([ @@ -217,7 +214,7 @@ describe('action_form', () => { mutedInstanceIds: [], } as unknown) as Alert; - wrapper = mountWithIntl( + const wrapper = mountWithIntl( { setActionIdByIndex={(id: string, index: number) => { initialAlert.actions[index].id = id; }} + actionGroups={[{ id: 'default', name: 'Default' }]} + setActionGroupIdByIndex={(group: string, index: number) => { + initialAlert.actions[index].group = group; + }} setAlertProperty={(_updatedActions: AlertAction[]) => {}} setActionParamsProperty={(key: string, value: any, index: number) => (initialAlert.actions[index] = { ...initialAlert.actions[index], [key]: value }) @@ -297,10 +298,12 @@ describe('action_form', () => { await nextTick(); wrapper.update(); }); + + return wrapper; } it('renders available action cards', async () => { - await setup(); + const wrapper = await setup(); const actionOption = wrapper.find( `[data-test-subj="${actionType.id}-ActionTypeSelectOption"]` ); @@ -314,7 +317,7 @@ describe('action_form', () => { }); it('does not render action types disabled by config', async () => { - await setup(); + const wrapper = await setup(); const actionOption = wrapper.find( '[data-test-subj="disabled-by-config-ActionTypeSelectOption"]' ); @@ -322,52 +325,72 @@ describe('action_form', () => { }); it('render action types which is preconfigured only (disabled by config and with preconfigured connectors)', async () => { - await setup(); + const wrapper = await setup(); const actionOption = wrapper.find('[data-test-subj="preconfigured-ActionTypeSelectOption"]'); expect(actionOption.exists()).toBeTruthy(); }); + it('renders available action groups for the selected action type', async () => { + const wrapper = await setup(); + const actionOption = wrapper.find( + `[data-test-subj="${actionType.id}-ActionTypeSelectOption"]` + ); + actionOption.first().simulate('click'); + const actionGroupsSelect = wrapper.find( + `[data-test-subj="addNewActionConnectorActionGroup-0"]` + ); + expect((actionGroupsSelect.first().props() as any).options).toMatchInlineSnapshot(` + Array [ + Object { + "data-test-subj": "addNewActionConnectorActionGroup-0-option-default", + "inputDisplay": "Default", + "value": "default", + }, + ] + `); + }); + it('renders available connectors for the selected action type', async () => { - await setup(); + const wrapper = await setup(); const actionOption = wrapper.find( `[data-test-subj="${actionType.id}-ActionTypeSelectOption"]` ); actionOption.first().simulate('click'); const combobox = wrapper.find(`[data-test-subj="selectActionConnector-${actionType.id}"]`); expect((combobox.first().props() as any).options).toMatchInlineSnapshot(` - Array [ - Object { - "id": "test", - "key": "test", - "label": "Test connector ", - }, - Object { - "id": "test2", - "key": "test2", - "label": "Test connector 2 (preconfigured)", - }, - ] - `); + Array [ + Object { + "id": "test", + "key": "test", + "label": "Test connector ", + }, + Object { + "id": "test2", + "key": "test2", + "label": "Test connector 2 (preconfigured)", + }, + ] + `); }); it('renders only preconfigured connectors for the selected preconfigured action type', async () => { - await setup(); + const wrapper = await setup(); const actionOption = wrapper.find('[data-test-subj="preconfigured-ActionTypeSelectOption"]'); actionOption.first().simulate('click'); const combobox = wrapper.find('[data-test-subj="selectActionConnector-preconfigured"]'); expect((combobox.first().props() as any).options).toMatchInlineSnapshot(` - Array [ - Object { - "id": "test3", - "key": "test3", - "label": "Preconfigured Only (preconfigured)", - }, - ] - `); + Array [ + Object { + "id": "test3", + "key": "test3", + "label": "Preconfigured Only (preconfigured)", + }, + ] + `); }); it('does not render "Add connector" button for preconfigured only action type', async () => { - await setup(); + const wrapper = await setup(); const actionOption = wrapper.find('[data-test-subj="preconfigured-ActionTypeSelectOption"]'); actionOption.first().simulate('click'); const preconfigPannel = wrapper.find('[data-test-subj="alertActionAccordion-default"]'); @@ -378,7 +401,7 @@ describe('action_form', () => { }); it('renders action types disabled by license', async () => { - await setup(); + const wrapper = await setup(); const actionOption = wrapper.find( '[data-test-subj="disabled-by-license-ActionTypeSelectOption"]' ); @@ -391,7 +414,7 @@ describe('action_form', () => { }); it(`shouldn't render action types without params component`, async () => { - await setup(); + const wrapper = await setup(); const actionOption = wrapper.find( `[data-test-subj="${actionTypeWithoutParams.id}-ActionTypeSelectOption"]` ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index 74432157f5659..3a7341afe3e07 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, Suspense, useState, useEffect } from 'react'; +import React, { Fragment, useState, useEffect, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -14,25 +14,13 @@ import { EuiIcon, EuiTitle, EuiSpacer, - EuiFormRow, - EuiComboBox, EuiKeyPadMenuItem, - EuiAccordion, - EuiButtonIcon, - EuiEmptyPrompt, - EuiButtonEmpty, EuiToolTip, - EuiIconTip, EuiLink, - EuiCallOut, - EuiHorizontalRule, - EuiText, - EuiLoadingSpinner, } from '@elastic/eui'; import { HttpSetup, ToastsSetup, ApplicationStart, DocLinksStart } from 'kibana/public'; import { loadActionTypes, loadAllActions as loadConnectors } from '../../lib/action_connector_api'; import { - IErrorObject, ActionTypeModel, ActionTypeRegistryContract, AlertAction, @@ -43,15 +31,19 @@ import { } from '../../../types'; import { SectionLoading } from '../../components/section_loading'; import { ConnectorAddModal } from './connector_add_modal'; +import { ActionTypeForm, ActionTypeFormProps } from './action_type_form'; +import { AddConnectorInline } from './connector_add_inline'; import { actionTypeCompare } from '../../lib/action_type_compare'; import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled'; import { VIEW_LICENSE_OPTIONS_LINK, DEFAULT_HIDDEN_ACTION_TYPES } from '../../../common/constants'; -import { hasSaveActionsCapability } from '../../lib/capabilities'; +import { ActionGroup } from '../../../../../alerts/common'; -interface ActionAccordionFormProps { +export interface ActionAccordionFormProps { actions: AlertAction[]; defaultActionGroupId: string; + actionGroups?: ActionGroup[]; setActionIdByIndex: (id: string, index: number) => void; + setActionGroupIdByIndex?: (group: string, index: number) => void; setAlertProperty: (actions: AlertAction[]) => void; setActionParamsProperty: (key: string, value: any, index: number) => void; http: HttpSetup; @@ -74,7 +66,9 @@ interface ActiveActionConnectorState { export const ActionForm = ({ actions, defaultActionGroupId, + actionGroups, setActionIdByIndex, + setActionGroupIdByIndex, setAlertProperty, setActionParamsProperty, http, @@ -88,8 +82,6 @@ export const ActionForm = ({ capabilities, docLinks, }: ActionAccordionFormProps) => { - const canSave = hasSaveActionsCapability(capabilities); - const [addModalVisible, setAddModalVisibility] = useState(false); const [activeActionItem, setActiveActionItem] = useState( undefined @@ -101,6 +93,10 @@ export const ActionForm = ({ const [actionTypesIndex, setActionTypesIndex] = useState(undefined); const [emptyActionsIds, setEmptyActionsIds] = useState([]); + const closeAddConnectorModal = useCallback(() => setAddModalVisibility(false), [ + setAddModalVisibility, + ]); + // load action types useEffect(() => { (async () => { @@ -183,359 +179,6 @@ export const ActionForm = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [actions, connectors]); - const preconfiguredMessage = i18n.translate( - 'xpack.triggersActionsUI.sections.actionForm.preconfiguredTitleMessage', - { - defaultMessage: '(preconfigured)', - } - ); - - const getSelectedOptions = (actionItemId: string) => { - const selectedConnector = connectors.find((connector) => connector.id === actionItemId); - if ( - !selectedConnector || - // if selected connector is not preconfigured and action type is for preconfiguration only, - // do not show regular connectors of this type - (actionTypesIndex && - !actionTypesIndex[selectedConnector.actionTypeId].enabledInConfig && - !selectedConnector.isPreconfigured) - ) { - return []; - } - const optionTitle = `${selectedConnector.name} ${ - selectedConnector.isPreconfigured ? preconfiguredMessage : '' - }`; - return [ - { - label: optionTitle, - value: optionTitle, - id: actionItemId, - 'data-test-subj': 'itemActionConnector', - }, - ]; - }; - - const getActionTypeForm = ( - actionItem: AlertAction, - actionConnector: ActionConnector, - actionParamsErrors: { - errors: IErrorObject; - }, - index: number - ) => { - if (!actionTypesIndex) { - return null; - } - - const actionType = actionTypesIndex[actionItem.actionTypeId]; - - const optionsList = connectors - .filter( - (connectorItem) => - connectorItem.actionTypeId === actionItem.actionTypeId && - // include only enabled by config connectors or preconfigured - (actionType.enabledInConfig || connectorItem.isPreconfigured) - ) - .map(({ name, id, isPreconfigured }) => ({ - label: `${name} ${isPreconfigured ? preconfiguredMessage : ''}`, - key: id, - id, - })); - const actionTypeRegistered = actionTypeRegistry.get(actionConnector.actionTypeId); - if (!actionTypeRegistered || actionItem.group !== defaultActionGroupId) return null; - const ParamsFieldsComponent = actionTypeRegistered.actionParamsFields; - const checkEnabledResult = checkActionFormActionTypeEnabled( - actionTypesIndex[actionConnector.actionTypeId], - connectors.filter((connector) => connector.isPreconfigured) - ); - - const accordionContent = checkEnabledResult.isEnabled ? ( - - - - - } - labelAppend={ - canSave && - actionTypesIndex && - actionTypesIndex[actionConnector.actionTypeId].enabledInConfig ? ( - { - setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); - setAddModalVisibility(true); - }} - > - - - ) : null - } - > - { - setActionIdByIndex(selectedOptions[0].id ?? '', index); - }} - isClearable={false} - /> - - - - - {ParamsFieldsComponent ? ( - - - - -
- } - > - - - ) : null} - - ) : ( - checkEnabledResult.messageCard - ); - - return ( - - - - - - - -
- - - - - - {checkEnabledResult.isEnabled === false && ( - - - - )} - - -
-
-
-
- } - extraAction={ - { - const updatedActions = actions.filter( - (_item: AlertAction, i: number) => i !== index - ); - setAlertProperty(updatedActions); - setIsAddActionPanelOpen( - updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === - 0 - ); - setActiveActionItem(undefined); - }} - /> - } - paddingSize="l" - > - {accordionContent} - - - - ); - }; - - const getAddConnectorsForm = (actionItem: AlertAction, index: number) => { - const actionTypeName = actionTypesIndex - ? actionTypesIndex[actionItem.actionTypeId].name - : actionItem.actionTypeId; - const actionTypeRegistered = actionTypeRegistry.get(actionItem.actionTypeId); - if (!actionTypeRegistered || actionItem.group !== defaultActionGroupId) return null; - - const noConnectorsLabel = ( - - ); - return ( - - - - - - - -
- -
-
-
-
- } - extraAction={ - { - const updatedActions = actions.filter( - (_item: AlertAction, i: number) => i !== index - ); - setAlertProperty(updatedActions); - setIsAddActionPanelOpen( - updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === - 0 - ); - setActiveActionItem(undefined); - }} - /> - } - paddingSize="l" - > - {canSave ? ( - actionItem.id === emptyId) ? ( - noConnectorsLabel - ) : ( - - ) - } - actions={[ - { - setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); - setAddModalVisibility(true); - }} - > - - , - ]} - /> - ) : ( - -

- -

-
- )} - - - - ); - }; - function addActionType(actionTypeModel: ActionTypeModel) { if (!defaultActionGroupId) { toastNotifications!.addDanger({ @@ -628,116 +271,172 @@ export const ActionForm = ({ }); } - const alertActionsList = actions.map((actionItem: AlertAction, index: number) => { - const actionConnector = connectors.find((field) => field.id === actionItem.id); - // connectors doesn't exists - if (!actionConnector) { - return getAddConnectorsForm(actionItem, index); - } - - const actionErrors: { errors: IErrorObject } = actionTypeRegistry - .get(actionItem.actionTypeId) - ?.validateParams(actionItem.params); - - return getActionTypeForm(actionItem, actionConnector, actionErrors, index); - }); - - return ( + return isLoadingConnectors ? ( + + + + ) : ( - {isLoadingConnectors ? ( - + +

- - ) : ( - - -

- + + + {actionTypesIndex && + actions.map((actionItem: AlertAction, index: number) => { + const actionConnector = connectors.find((field) => field.id === actionItem.id); + // connectors doesn't exists + if (!actionConnector) { + return ( + { + const updatedActions = actions.filter( + (_item: AlertAction, i: number) => i !== index + ); + setAlertProperty(updatedActions); + setIsAddActionPanelOpen( + updatedActions.filter((item: AlertAction) => item.id !== actionItem.id) + .length === 0 + ); + setActiveActionItem(undefined); + }} + onAddConnector={() => { + setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); + setAddModalVisibility(true); + }} /> -

-
- - {alertActionsList} - {isAddActionPanelOpen === false ? ( -
- - - - setIsAddActionPanelOpen(true)} - > - - - - -
- ) : null} - {isAddActionPanelOpen ? ( - - - - -
+ ); + } + + const actionParamsErrors: ActionTypeFormProps['actionParamsErrors'] = actionTypeRegistry + .get(actionItem.actionTypeId) + ?.validateParams(actionItem.params); + + return ( + { + setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); + setAddModalVisibility(true); + }} + onConnectorSelected={(id: string) => { + setActionIdByIndex(id, index); + }} + onDeleteAction={() => { + const updatedActions = actions.filter( + (_item: AlertAction, i: number) => i !== index + ); + setAlertProperty(updatedActions); + setIsAddActionPanelOpen( + updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === + 0 + ); + setActiveActionItem(undefined); + }} + /> + ); + })} + + {isAddActionPanelOpen ? ( + + + + +
+ +
+
+
+ {hasDisabledByLicenseActionTypes && ( + + +
+ -
-
-
- {hasDisabledByLicenseActionTypes && ( - - -
- - - -
-
-
- )} -
- - - {isLoadingActionTypes ? ( - - - - ) : ( - actionTypeNodes - )} - -
- ) : null} + +
+
+
+ )} +
+ + + {isLoadingActionTypes ? ( + + + + ) : ( + actionTypeNodes + )} +
+ ) : ( + + + setIsAddActionPanelOpen(true)} + > + + + + )} - {actionTypesIndex && activeActionItem ? ( + {actionTypesIndex && activeActionItem && addModalVisible ? ( { connectors.push(savedAction); setActionIdByIndex(savedAction.id, activeActionItem.index); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx new file mode 100644 index 0000000000000..38468283b9c19 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx @@ -0,0 +1,339 @@ +/* + * 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, Suspense, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiSpacer, + EuiFormRow, + EuiComboBox, + EuiAccordion, + EuiButtonIcon, + EuiButtonEmpty, + EuiIconTip, + EuiText, + EuiFormLabel, + EuiFormControlLayout, + EuiSuperSelect, + EuiLoadingSpinner, + EuiBadge, +} from '@elastic/eui'; +import { IErrorObject, AlertAction, ActionTypeIndex, ActionConnector } from '../../../types'; +import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled'; +import { hasSaveActionsCapability } from '../../lib/capabilities'; +import { ActionAccordionFormProps } from './action_form'; + +export type ActionTypeFormProps = { + actionItem: AlertAction; + actionConnector: ActionConnector; + actionParamsErrors: { + errors: IErrorObject; + }; + index: number; + onAddConnector: () => void; + onConnectorSelected: (id: string) => void; + onDeleteAction: () => void; + setActionParamsProperty: (key: string, value: any, index: number) => void; + actionTypesIndex: ActionTypeIndex; + connectors: ActionConnector[]; +} & Pick< + ActionAccordionFormProps, + | 'defaultActionGroupId' + | 'actionGroups' + | 'setActionGroupIdByIndex' + | 'setActionParamsProperty' + | 'http' + | 'actionTypeRegistry' + | 'toastNotifications' + | 'docLinks' + | 'messageVariables' + | 'defaultActionMessage' + | 'capabilities' +>; + +const preconfiguredMessage = i18n.translate( + 'xpack.triggersActionsUI.sections.actionForm.preconfiguredTitleMessage', + { + defaultMessage: '(preconfigured)', + } +); + +export const ActionTypeForm = ({ + actionItem, + actionConnector, + actionParamsErrors, + index, + onAddConnector, + onConnectorSelected, + onDeleteAction, + setActionParamsProperty, + actionTypesIndex, + connectors, + http, + toastNotifications, + docLinks, + capabilities, + actionTypeRegistry, + defaultActionGroupId, + defaultActionMessage, + messageVariables, + actionGroups, + setActionGroupIdByIndex, +}: ActionTypeFormProps) => { + const [isOpen, setIsOpen] = useState(true); + + const canSave = hasSaveActionsCapability(capabilities); + const getSelectedOptions = (actionItemId: string) => { + const selectedConnector = connectors.find((connector) => connector.id === actionItemId); + if ( + !selectedConnector || + // if selected connector is not preconfigured and action type is for preconfiguration only, + // do not show regular connectors of this type + (actionTypesIndex && + !actionTypesIndex[selectedConnector.actionTypeId].enabledInConfig && + !selectedConnector.isPreconfigured) + ) { + return []; + } + const optionTitle = `${selectedConnector.name} ${ + selectedConnector.isPreconfigured ? preconfiguredMessage : '' + }`; + return [ + { + label: optionTitle, + value: optionTitle, + id: actionItemId, + 'data-test-subj': 'itemActionConnector', + }, + ]; + }; + + const actionType = actionTypesIndex[actionItem.actionTypeId]; + + const optionsList = connectors + .filter( + (connectorItem) => + connectorItem.actionTypeId === actionItem.actionTypeId && + // include only enabled by config connectors or preconfigured + (actionType.enabledInConfig || connectorItem.isPreconfigured) + ) + .map(({ name, id, isPreconfigured }) => ({ + label: `${name} ${isPreconfigured ? preconfiguredMessage : ''}`, + key: id, + id, + })); + const actionTypeRegistered = actionTypeRegistry.get(actionConnector.actionTypeId); + if (!actionTypeRegistered) return null; + + const ParamsFieldsComponent = actionTypeRegistered.actionParamsFields; + const checkEnabledResult = checkActionFormActionTypeEnabled( + actionTypesIndex[actionConnector.actionTypeId], + connectors.filter((connector) => connector.isPreconfigured) + ); + + const defaultActionGroup = actionGroups?.find(({ id }) => id === defaultActionGroupId); + const selectedActionGroup = + actionGroups?.find(({ id }) => id === actionItem.group) ?? defaultActionGroup; + + const accordionContent = checkEnabledResult.isEnabled ? ( + + {actionGroups && selectedActionGroup && setActionGroupIdByIndex && ( + + + + + + + } + > + ({ + value, + inputDisplay: name, + 'data-test-subj': `addNewActionConnectorActionGroup-${index}-option-${value}`, + }))} + valueOfSelected={selectedActionGroup.id} + onChange={(group) => { + setActionGroupIdByIndex(group, index); + }} + /> + + + + + + )} + + + + } + labelAppend={ + canSave && + actionTypesIndex && + actionTypesIndex[actionConnector.actionTypeId].enabledInConfig ? ( + + + + ) : null + } + > + { + onConnectorSelected(selectedOptions[0].id ?? ''); + }} + isClearable={false} + /> + + + + + {ParamsFieldsComponent ? ( + + + + + + } + > + + + ) : null} + + ) : ( + checkEnabledResult.messageCard + ); + + return ( + + + + + + + +
+ + + + + {selectedActionGroup && !isOpen && ( + + {selectedActionGroup.name} + + )} + + {checkEnabledResult.isEnabled === false && ( + + + + )} + + +
+
+
+ + } + extraAction={ + + } + > + {accordionContent} +
+ +
+ ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx new file mode 100644 index 0000000000000..97baf4a36cb4c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiSpacer, + EuiAccordion, + EuiButtonIcon, + EuiEmptyPrompt, + EuiCallOut, + EuiText, +} from '@elastic/eui'; +import { AlertAction, ActionTypeIndex } from '../../../types'; +import { hasSaveActionsCapability } from '../../lib/capabilities'; +import { ActionAccordionFormProps } from './action_form'; + +type AddConnectorInFormProps = { + actionTypesIndex: ActionTypeIndex; + actionItem: AlertAction; + index: number; + onAddConnector: () => void; + onDeleteConnector: () => void; + emptyActionsIds: string[]; +} & Pick; + +export const AddConnectorInline = ({ + actionTypesIndex, + actionItem, + index, + onAddConnector, + onDeleteConnector, + actionTypeRegistry, + emptyActionsIds, + defaultActionGroupId, + capabilities, +}: AddConnectorInFormProps) => { + const canSave = hasSaveActionsCapability(capabilities); + + const actionTypeName = actionTypesIndex + ? actionTypesIndex[actionItem.actionTypeId].name + : actionItem.actionTypeId; + const actionTypeRegistered = actionTypeRegistry.get(actionItem.actionTypeId); + if (!actionTypeRegistered || actionItem.group !== defaultActionGroupId) return null; + + const noConnectorsLabel = ( + + ); + return ( + + + + + + + +
+ +
+
+
+ + } + extraAction={ + + } + paddingSize="l" + > + {canSave ? ( + actionItem.id === emptyId) ? ( + noConnectorsLabel + ) : ( + + ) + } + actions={[ + + + , + ]} + /> + ) : ( + +

+ +

+
+ )} +
+ +
+ ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx index cba9eea3cf3f7..71a3936ed5055 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx @@ -65,8 +65,7 @@ describe('connector_add_modal', () => { const wrapper = mountWithIntl( {}} + onClose={() => {}} actionType={actionType} http={deps!.http} actionTypeRegistry={deps!.actionTypeRegistry} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx index 13ec8395aa557..de27256bf566c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx @@ -32,8 +32,7 @@ import { interface ConnectorAddModalProps { actionType: ActionType; - addModalVisible: boolean; - setAddModalVisibility: React.Dispatch>; + onClose: () => void; postSaveEventHandler?: (savedAction: ActionConnector) => void; http: HttpSetup; actionTypeRegistry: ActionTypeRegistryContract; @@ -48,8 +47,7 @@ interface ConnectorAddModalProps { export const ConnectorAddModal = ({ actionType, - addModalVisible, - setAddModalVisibility, + onClose, postSaveEventHandler, http, toastNotifications, @@ -79,14 +77,11 @@ export const ConnectorAddModal = ({ >(undefined); const closeModal = useCallback(() => { - setAddModalVisibility(false); setConnector(initialConnector); setServerError(undefined); - }, [initialConnector, setAddModalVisibility]); + onClose(); + }, [initialConnector, onClose]); - if (!addModalVisible) { - return null; - } const actionTypeModel = actionTypeRegistry.get(actionType.id); const errors = { ...actionTypeModel?.validateConnector(connector).errors, 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 662db81101eee..70b6fb0b750dd 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 @@ -11,7 +11,10 @@ import { Alert, ActionType, ValidationResult } from '../../../../types'; import { EuiTitle, EuiBadge, EuiFlexItem, EuiSwitch, EuiButtonEmpty, EuiText } from '@elastic/eui'; import { ViewInApp } from './view_in_app'; import { coreMock } from 'src/core/public/mocks'; -import { ALERTS_FEATURE_ID } from '../../../../../../alerts/common'; +import { + AlertExecutionStatusErrorReasons, + ALERTS_FEATURE_ID, +} from '../../../../../../alerts/common'; const mockes = coreMock.createSetup(); @@ -125,7 +128,7 @@ describe('alert_details', () => { status: 'error', lastExecutionDate: new Date('2020-08-20T19:23:38Z'), error: { - reason: 'unknown', + reason: AlertExecutionStatusErrorReasons.Unknown, message: 'test', }, }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index 9a637ea750f81..20ad9a8d7c701 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, useState, useEffect, Suspense } from 'react'; +import React, { Fragment, useState, useEffect, Suspense, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -153,9 +153,17 @@ export const AlertForm = ({ setAlertTypeModel(alert.alertTypeId ? alertTypeRegistry.get(alert.alertTypeId) : null); }, [alert, alertTypeRegistry]); - const setAlertProperty = (key: string, value: any) => { - dispatch({ command: { type: 'setProperty' }, payload: { key, value } }); - }; + const setAlertProperty = useCallback( + (key: string, value: any) => { + dispatch({ command: { type: 'setProperty' }, payload: { key, value } }); + }, + [dispatch] + ); + + const setActions = useCallback( + (updatedActions: AlertAction[]) => setAlertProperty('actions', updatedActions), + [setAlertProperty] + ); const setAlertParams = (key: string, value: any) => { dispatch({ command: { type: 'setAlertParams' }, payload: { key, value } }); @@ -169,9 +177,12 @@ export const AlertForm = ({ dispatch({ command: { type: 'setAlertActionProperty' }, payload: { key, value, index } }); }; - const setActionParamsProperty = (key: string, value: any, index: number) => { - dispatch({ command: { type: 'setAlertActionParams' }, payload: { key, value, index } }); - }; + const setActionParamsProperty = useCallback( + (key: string, value: any, index: number) => { + dispatch({ command: { type: 'setAlertActionParams' }, payload: { key, value, index } }); + }, + [dispatch] + ); const tagsOptions = alert.tags ? alert.tags.map((label: string) => ({ label })) : []; @@ -202,6 +213,7 @@ export const AlertForm = ({ label={item.name} onClick={() => { setAlertProperty('alertTypeId', item.id); + setActions([]); setAlertTypeModel(item); setAlertProperty('params', {}); if (alertTypesIndex && alertTypesIndex.has(item.id)) { @@ -289,26 +301,25 @@ export const AlertForm = ({ /> ) : null} - {canShowActions && defaultActionGroupId ? ( + {canShowActions && + defaultActionGroupId && + alertTypeModel && + alertTypesIndex?.has(alert.alertTypeId) ? ( - a.name.toUpperCase().localeCompare(b.name.toUpperCase()) - ) - : undefined - } + messageVariables={actionVariablesFromAlertType( + alertTypesIndex.get(alert.alertTypeId)! + ).sort((a, b) => a.name.toUpperCase().localeCompare(b.name.toUpperCase()))} defaultActionGroupId={defaultActionGroupId} + actionGroups={alertTypesIndex.get(alert.alertTypeId)!.actionGroups} setActionIdByIndex={(id: string, index: number) => setActionProperty('id', id, index)} - setAlertProperty={(updatedActions: AlertAction[]) => - setAlertProperty('actions', updatedActions) - } - setActionParamsProperty={(key: string, value: any, index: number) => - setActionParamsProperty(key, value, index) + setActionGroupIdByIndex={(group: string, index: number) => + setActionProperty('group', group, index) } + setAlertProperty={setActions} + setActionParamsProperty={setActionParamsProperty} http={http} actionTypeRegistry={actionTypeRegistry} defaultActionMessage={alertTypeModel?.defaultActionMessage} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx index 18cc7b540296e..c434ca9d21402 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx @@ -17,7 +17,10 @@ import { AppContextProvider } from '../../../app_context'; import { chartPluginMock } from '../../../../../../../../src/plugins/charts/public/mocks'; import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks'; import { alertingPluginMock } from '../../../../../../alerts/public/mocks'; -import { ALERTS_FEATURE_ID } from '../../../../../../alerts/common'; +import { + AlertExecutionStatusErrorReasons, + ALERTS_FEATURE_ID, +} from '../../../../../../alerts/common'; import { featuresPluginMock } from '../../../../../../features/public/mocks'; jest.mock('../../../lib/action_connector_api', () => ({ @@ -245,7 +248,7 @@ describe('alerts_list component with items', () => { status: 'error', lastExecutionDate: new Date('2020-08-20T19:23:38Z'), error: { - reason: 'unknown', + reason: AlertExecutionStatusErrorReasons.Unknown, message: 'test', }, }, diff --git a/x-pack/plugins/uptime/public/components/overview/empty_state/__tests__/__snapshots__/empty_state.test.tsx.snap b/x-pack/plugins/uptime/public/components/overview/empty_state/__tests__/__snapshots__/empty_state.test.tsx.snap index ab38ee9adc6c2..6fcce75cea70e 100644 --- a/x-pack/plugins/uptime/public/components/overview/empty_state/__tests__/__snapshots__/empty_state.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/overview/empty_state/__tests__/__snapshots__/empty_state.test.tsx.snap @@ -1823,9 +1823,7 @@ exports[`EmptyState component renders error message when an error occurs 1`] = `
-

+

There was an error fetching your data.

diff --git a/x-pack/plugins/uptime/public/components/overview/empty_state/__tests__/empty_state.test.tsx b/x-pack/plugins/uptime/public/components/overview/empty_state/__tests__/empty_state.test.tsx index 6328789d03f29..0de5cd3ab31be 100644 --- a/x-pack/plugins/uptime/public/components/overview/empty_state/__tests__/empty_state.test.tsx +++ b/x-pack/plugins/uptime/public/components/overview/empty_state/__tests__/empty_state.test.tsx @@ -42,7 +42,9 @@ describe('EmptyState component', () => { it(`renders error message when an error occurs`, () => { const errors: IHttpFetchError[] = [ - new HttpFetchError('There was an error fetching your data.', 'error', {} as any), + new HttpFetchError('There was an error fetching your data.', 'error', {} as any, {} as any, { + body: { message: 'There was an error fetching your data.' }, + }), ]; const component = mountWithRouter( diff --git a/x-pack/plugins/uptime/public/components/overview/empty_state/empty_state_error.tsx b/x-pack/plugins/uptime/public/components/overview/empty_state/empty_state_error.tsx index f7b77df8497f9..165b123d8884d 100644 --- a/x-pack/plugins/uptime/public/components/overview/empty_state/empty_state_error.tsx +++ b/x-pack/plugins/uptime/public/components/overview/empty_state/empty_state_error.tsx @@ -15,7 +15,7 @@ interface EmptyStateErrorProps { export const EmptyStateError = ({ errors }: EmptyStateErrorProps) => { const unauthorized = errors.find( - (error: Error) => error.message && error.message.includes('unauthorized') + (error: IHttpFetchError) => error.message && error.message.includes('unauthorized') ); return ( @@ -46,7 +46,9 @@ export const EmptyStateError = ({ errors }: EmptyStateErrorProps) => { body={ {!unauthorized && - errors.map((error: Error) =>

{error.message}

)} + errors.map((error: IHttpFetchError) => ( +

{error.body.message || error.message}

+ ))}
} /> diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/monitor_list.test.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/monitor_list.test.tsx index 1d8a7a771e0c5..352369cfdb72b 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/monitor_list.test.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/monitor_list.test.tsx @@ -17,6 +17,7 @@ import { MonitorListComponent, noItemsMessage } from '../monitor_list'; import { renderWithRouter, shallowWithRouter } from '../../../../lib'; import * as redux from 'react-redux'; import moment from 'moment'; +import { IHttpFetchError } from '../../../../../../../../src/core/public'; jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => { return { @@ -187,7 +188,11 @@ describe('MonitorList component', () => { it('renders error list', () => { const component = shallowWithRouter( diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx index 5e0cc5d3dee1d..f31e25484a936 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx @@ -187,7 +187,7 @@ export const MonitorListComponent: ({ ( { @@ -41,7 +42,7 @@ export const monitorListReducer = handleActions( error: undefined, list: { ...action.payload }, }), - [String(getMonitorListFailure)]: (state: MonitorList, action: Action) => ({ + [String(getMonitorListFailure)]: (state: MonitorList, action: Action) => ({ ...state, error: action.payload, loading: false, diff --git a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts index b75c729c2104a..cd98ba1600d34 100644 --- a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts @@ -9,17 +9,20 @@ import { IRouter, SavedObjectsClientContract, ISavedObjectsRepository, - ILegacyScopedClusterClient, + IScopedClusterClient, + ElasticsearchClient, } from 'src/core/server'; import { UMKibanaRoute } from '../../../rest_api'; import { PluginSetupContract } from '../../../../../features/server'; import { DynamicSettings } from '../../../../common/runtime_types'; import { MlPluginSetup as MlSetup } from '../../../../../ml/server'; -export type ESAPICaller = ILegacyScopedClusterClient['callAsCurrentUser']; - export type UMElasticsearchQueryFn = ( - params: { callES: ESAPICaller; dynamicSettings: DynamicSettings } & P + params: { + callES: ElasticsearchClient; + esClient?: IScopedClusterClient; + dynamicSettings: DynamicSettings; + } & P ) => Promise; export type UMSavedObjectsQueryFn = ( diff --git a/x-pack/plugins/uptime/server/lib/adapters/telemetry/kibana_telemetry_adapter.ts b/x-pack/plugins/uptime/server/lib/adapters/telemetry/kibana_telemetry_adapter.ts index a8969f2621f29..2126b484b1cfd 100644 --- a/x-pack/plugins/uptime/server/lib/adapters/telemetry/kibana_telemetry_adapter.ts +++ b/x-pack/plugins/uptime/server/lib/adapters/telemetry/kibana_telemetry_adapter.ts @@ -5,10 +5,14 @@ */ import moment from 'moment'; -import { ISavedObjectsRepository, SavedObjectsClientContract } from 'kibana/server'; +import { + ISavedObjectsRepository, + ILegacyScopedClusterClient, + SavedObjectsClientContract, + ElasticsearchClient, +} from 'kibana/server'; import { CollectorFetchContext, UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { PageViewParams, UptimeTelemetry, Usage } from './types'; -import { ESAPICaller } from '../framework'; import { savedObjectsAdapter } from '../../saved_objects'; interface UptimeTelemetryCollector { @@ -21,6 +25,8 @@ const BUCKET_SIZE = 3600; const BUCKET_NUMBER = 24; export class KibanaTelemetryAdapter { + public static callCluster: ILegacyScopedClusterClient['callAsCurrentUser'] | ElasticsearchClient; + public static registerUsageCollector = ( usageCollector: UsageCollectionSetup, getSavedObjectsClient: () => ISavedObjectsRepository | undefined @@ -125,7 +131,7 @@ export class KibanaTelemetryAdapter { } public static async countNoOfUniqueMonitorAndLocations( - callCluster: ESAPICaller, + callCluster: ILegacyScopedClusterClient['callAsCurrentUser'] | ElasticsearchClient, savedObjectsClient: ISavedObjectsRepository | SavedObjectsClientContract ) { const dynamicSettings = await savedObjectsAdapter.getUptimeDynamicSettings(savedObjectsClient); @@ -187,7 +193,11 @@ export class KibanaTelemetryAdapter { }, }; - const result = await callCluster('search', params); + const { body: result } = + typeof callCluster === 'function' + ? await callCluster('search', params) + : await callCluster.search(params); + const numberOfUniqueMonitors: number = result?.aggregations?.unique_monitors?.value ?? 0; const numberOfUniqueLocations: number = result?.aggregations?.unique_locations?.value ?? 0; const monitorNameStats: any = result?.aggregations?.monitor_name; diff --git a/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts b/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts index 06b298aedeb2b..ccb1e5a40ad2d 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts @@ -56,6 +56,8 @@ const mockOptions = ( services = alertsMock.createAlertServices(), state = {} ): any => { + services.scopedClusterClient = jest.fn() as any; + services.savedObjectsClient.get.mockResolvedValue({ id: '', type: '', @@ -282,7 +284,8 @@ describe('status check alert', () => { expect.assertions(5); toISOStringSpy.mockImplementation(() => 'foo date string'); const mockGetter: jest.Mock = jest.fn(); - mockGetter.mockReturnValue([ + + mockGetter.mockReturnValueOnce([ { monitorId: 'first', location: 'harrisburg', @@ -326,6 +329,7 @@ describe('status check alert', () => { const state = await alert.executor(options); const [{ value: alertInstanceMock }] = alertServices.alertInstanceFactory.mock.results; expect(mockGetter).toHaveBeenCalledTimes(1); + expect(mockGetter.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { diff --git a/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts b/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts index 9dddc0035f690..d4c26fe83b5fc 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts @@ -12,12 +12,12 @@ import { ACTION_GROUP_DEFINITIONS } from '../../../common/constants/alerts'; import { commonStateTranslations, durationAnomalyTranslations } from './translations'; import { AnomaliesTableRecord } from '../../../../ml/common/types/anomalies'; import { getSeverityType } from '../../../../ml/common/util/anomaly_utils'; -import { savedObjectsAdapter } from '../saved_objects'; import { UptimeCorePlugins } from '../adapters/framework'; import { UptimeAlertTypeFactory } from './types'; import { Ping } from '../../../common/runtime_types/ping'; import { getMLJobId } from '../../../common/lib'; import { getLatestMonitor } from '../requests/get_latest_monitor'; +import { uptimeAlertWrapper } from './uptime_alert_wrapper'; const { DURATION_ANOMALY } = ACTION_GROUP_DEFINITIONS; @@ -61,61 +61,58 @@ const getAnomalies = async ( ); }; -export const durationAnomalyAlertFactory: UptimeAlertTypeFactory = (_server, _libs, plugins) => ({ - id: 'xpack.uptime.alerts.durationAnomaly', - name: durationAnomalyTranslations.alertFactoryName, - validate: { - params: schema.object({ - monitorId: schema.string(), - severity: schema.number(), - }), - }, - defaultActionGroupId: DURATION_ANOMALY.id, - actionGroups: [ - { - id: DURATION_ANOMALY.id, - name: DURATION_ANOMALY.name, +export const durationAnomalyAlertFactory: UptimeAlertTypeFactory = (_server, _libs, plugins) => + uptimeAlertWrapper({ + id: 'xpack.uptime.alerts.durationAnomaly', + name: durationAnomalyTranslations.alertFactoryName, + validate: { + params: schema.object({ + monitorId: schema.string(), + severity: schema.number(), + }), }, - ], - actionVariables: { - context: [], - state: [...durationAnomalyTranslations.actionVariables, ...commonStateTranslations], - }, - producer: 'uptime', - async executor(options) { - const { - services: { alertInstanceFactory, callCluster, savedObjectsClient }, - state, - params, - } = options; + defaultActionGroupId: DURATION_ANOMALY.id, + actionGroups: [ + { + id: DURATION_ANOMALY.id, + name: DURATION_ANOMALY.name, + }, + ], + actionVariables: { + context: [], + state: [...durationAnomalyTranslations.actionVariables, ...commonStateTranslations], + }, + async executor({ options, esClient, savedObjectsClient, dynamicSettings }) { + const { + services: { alertInstanceFactory }, + state, + params, + } = options; - const { anomalies } = - (await getAnomalies(plugins, savedObjectsClient, params, state.lastCheckedAt)) ?? {}; + const { anomalies } = + (await getAnomalies(plugins, savedObjectsClient, params, state.lastCheckedAt)) ?? {}; - const foundAnomalies = anomalies?.length > 0; + const foundAnomalies = anomalies?.length > 0; - if (foundAnomalies) { - const dynamicSettings = await savedObjectsAdapter.getUptimeDynamicSettings( - savedObjectsClient - ); - const monitorInfo = await getLatestMonitor({ - dynamicSettings, - callES: callCluster, - dateStart: 'now-15m', - dateEnd: 'now', - monitorId: params.monitorId, - }); - anomalies.forEach((anomaly, index) => { - const alertInstance = alertInstanceFactory(DURATION_ANOMALY.id + index); - const summary = getAnomalySummary(anomaly, monitorInfo); - alertInstance.replaceState({ - ...updateState(state, false), - ...summary, + if (foundAnomalies) { + const monitorInfo = await getLatestMonitor({ + dynamicSettings, + callES: esClient, + dateStart: 'now-15m', + dateEnd: 'now', + monitorId: params.monitorId, + }); + anomalies.forEach((anomaly, index) => { + const alertInstance = alertInstanceFactory(DURATION_ANOMALY.id + index); + const summary = getAnomalySummary(anomaly, monitorInfo); + alertInstance.replaceState({ + ...updateState(state, false), + ...summary, + }); + alertInstance.scheduleActions(DURATION_ANOMALY.id); }); - alertInstance.scheduleActions(DURATION_ANOMALY.id); - }); - } + } - return updateState(state, foundAnomalies); - }, -}); + return updateState(state, foundAnomalies); + }, + }); diff --git a/x-pack/plugins/uptime/server/lib/alerts/status_check.ts b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts index 7feb916046e3a..b1b3666b40dc6 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/status_check.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts @@ -26,7 +26,6 @@ import { GetMonitorStatusResult } from '../requests/get_monitor_status'; import { UNNAMED_LOCATION } from '../../../common/constants'; import { uptimeAlertWrapper } from './uptime_alert_wrapper'; import { MonitorStatusTranslations } from '../../../common/translations'; -import { ESAPICaller } from '../adapters/framework'; import { getUptimeIndexPattern, IndexPatternTitleAndFields } from '../requests/get_index_pattern'; import { UMServerLibs } from '../lib'; @@ -81,7 +80,6 @@ export const generateFilterDSL = async ( export const formatFilterString = async ( dynamicSettings: DynamicSettings, - callES: ESAPICaller, esClient: ElasticsearchClient, filters: StatusCheckFilters, search: string, @@ -90,9 +88,8 @@ export const formatFilterString = async ( await generateFilterDSL( () => libs?.requests?.getIndexPattern - ? libs?.requests?.getIndexPattern({ callES, esClient, dynamicSettings }) + ? libs?.requests?.getIndexPattern({ esClient, dynamicSettings }) : getUptimeIndexPattern({ - callES, esClient, dynamicSettings, }), @@ -237,12 +234,15 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = (_server, libs) = ], state: [...commonMonitorStateI18, ...commonStateTranslations], }, - async executor( - { params: rawParams, state, services: { alertInstanceFactory } }, - callES, + async executor({ + options: { + params: rawParams, + state, + services: { alertInstanceFactory }, + }, esClient, - dynamicSettings - ) { + dynamicSettings, + }) { const { filters, search, @@ -258,7 +258,6 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = (_server, libs) = const filterString = await formatFilterString( dynamicSettings, - callES, esClient, filters, search, @@ -278,7 +277,7 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = (_server, libs) = // after that shouldCheckStatus should be explicitly false if (!(!oldVersionTimeRange && shouldCheckStatus === false)) { downMonitorsByLocation = await libs.requests.getMonitorStatus({ - callES, + callES: esClient, dynamicSettings, timerange, numTimes, @@ -311,7 +310,7 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = (_server, libs) = let availabilityResults: GetMonitorAvailabilityResult[] = []; if (shouldCheckAvailability) { availabilityResults = await libs.requests.getMonitorAvailability({ - callES, + callES: esClient, dynamicSettings, ...availability, filters: JSON.stringify(filterString) || undefined, diff --git a/x-pack/plugins/uptime/server/lib/alerts/tls.ts b/x-pack/plugins/uptime/server/lib/alerts/tls.ts index d4853ad7a9cb0..11f602d10bf51 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/tls.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/tls.ts @@ -7,13 +7,13 @@ import moment from 'moment'; import { schema } from '@kbn/config-schema'; import { UptimeAlertTypeFactory } from './types'; -import { savedObjectsAdapter } from '../saved_objects'; import { updateState } from './common'; import { ACTION_GROUP_DEFINITIONS } from '../../../common/constants/alerts'; import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../common/constants'; import { Cert, CertResult } from '../../../common/runtime_types'; import { commonStateTranslations, tlsTranslations } from './translations'; import { DEFAULT_FROM, DEFAULT_TO } from '../../rest_api/certs/certs'; +import { uptimeAlertWrapper } from './uptime_alert_wrapper'; const { TLS } = ACTION_GROUP_DEFINITIONS; @@ -82,74 +82,73 @@ export const getCertSummary = ( }; }; -export const tlsAlertFactory: UptimeAlertTypeFactory = (_server, libs) => ({ - id: 'xpack.uptime.alerts.tls', - name: tlsTranslations.alertFactoryName, - validate: { - params: schema.object({}), - }, - defaultActionGroupId: TLS.id, - actionGroups: [ - { - id: TLS.id, - name: TLS.name, +export const tlsAlertFactory: UptimeAlertTypeFactory = (_server, libs) => + uptimeAlertWrapper({ + id: 'xpack.uptime.alerts.tls', + name: tlsTranslations.alertFactoryName, + validate: { + params: schema.object({}), }, - ], - actionVariables: { - context: [], - state: [...tlsTranslations.actionVariables, ...commonStateTranslations], - }, - producer: 'uptime', - async executor(options) { - const { - services: { alertInstanceFactory, callCluster, savedObjectsClient }, - state, - } = options; - const dynamicSettings = await savedObjectsAdapter.getUptimeDynamicSettings(savedObjectsClient); - - const { certs, total }: CertResult = await libs.requests.getCerts({ - callES: callCluster, - dynamicSettings, - from: DEFAULT_FROM, - to: DEFAULT_TO, - index: 0, - size: DEFAULT_SIZE, - notValidAfter: `now+${ - dynamicSettings?.certExpirationThreshold ?? - DYNAMIC_SETTINGS_DEFAULTS.certExpirationThreshold - }d`, - notValidBefore: `now-${ - dynamicSettings?.certAgeThreshold ?? DYNAMIC_SETTINGS_DEFAULTS.certAgeThreshold - }d`, - sortBy: 'common_name', - direction: 'desc', - }); - - const foundCerts = total > 0; - - if (foundCerts) { - const absoluteExpirationThreshold = moment() - .add( - dynamicSettings.certExpirationThreshold ?? - DYNAMIC_SETTINGS_DEFAULTS.certExpirationThreshold, - 'd' - ) - .valueOf(); - const absoluteAgeThreshold = moment() - .subtract( - dynamicSettings.certAgeThreshold ?? DYNAMIC_SETTINGS_DEFAULTS.certAgeThreshold, - 'd' - ) - .valueOf(); - const alertInstance = alertInstanceFactory(TLS.id); - const summary = getCertSummary(certs, absoluteExpirationThreshold, absoluteAgeThreshold); - alertInstance.replaceState({ - ...updateState(state, foundCerts), - ...summary, + defaultActionGroupId: TLS.id, + actionGroups: [ + { + id: TLS.id, + name: TLS.name, + }, + ], + actionVariables: { + context: [], + state: [...tlsTranslations.actionVariables, ...commonStateTranslations], + }, + async executor({ options, dynamicSettings, esClient }) { + const { + services: { alertInstanceFactory }, + state, + } = options; + + const { certs, total }: CertResult = await libs.requests.getCerts({ + callES: esClient, + dynamicSettings, + from: DEFAULT_FROM, + to: DEFAULT_TO, + index: 0, + size: DEFAULT_SIZE, + notValidAfter: `now+${ + dynamicSettings?.certExpirationThreshold ?? + DYNAMIC_SETTINGS_DEFAULTS.certExpirationThreshold + }d`, + notValidBefore: `now-${ + dynamicSettings?.certAgeThreshold ?? DYNAMIC_SETTINGS_DEFAULTS.certAgeThreshold + }d`, + sortBy: 'common_name', + direction: 'desc', }); - alertInstance.scheduleActions(TLS.id); - } - return updateState(state, foundCerts); - }, -}); + const foundCerts = total > 0; + + if (foundCerts) { + const absoluteExpirationThreshold = moment() + .add( + dynamicSettings.certExpirationThreshold ?? + DYNAMIC_SETTINGS_DEFAULTS.certExpirationThreshold, + 'd' + ) + .valueOf(); + const absoluteAgeThreshold = moment() + .subtract( + dynamicSettings.certAgeThreshold ?? DYNAMIC_SETTINGS_DEFAULTS.certAgeThreshold, + 'd' + ) + .valueOf(); + const alertInstance = alertInstanceFactory(TLS.id); + const summary = getCertSummary(certs, absoluteExpirationThreshold, absoluteAgeThreshold); + alertInstance.replaceState({ + ...updateState(state, foundCerts), + ...summary, + }); + alertInstance.scheduleActions(TLS.id); + } + + return updateState(state, foundCerts); + }, + }); diff --git a/x-pack/plugins/uptime/server/lib/alerts/uptime_alert_wrapper.ts b/x-pack/plugins/uptime/server/lib/alerts/uptime_alert_wrapper.ts index 390b6d347996c..0961eb6557891 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/uptime_alert_wrapper.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/uptime_alert_wrapper.ts @@ -4,18 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient, ElasticsearchClient } from 'kibana/server'; +import { ElasticsearchClient, SavedObjectsClientContract } from 'kibana/server'; import { AlertExecutorOptions, AlertType, AlertTypeState } from '../../../../alerts/server'; import { savedObjectsAdapter } from '../saved_objects'; import { DynamicSettings } from '../../../common/runtime_types'; export interface UptimeAlertType extends Omit { - executor: ( - options: AlertExecutorOptions, - callES: ILegacyScopedClusterClient['callAsCurrentUser'], - esClient: ElasticsearchClient, - dynamicSettings: DynamicSettings - ) => Promise; + executor: ({ + options, + esClient, + dynamicSettings, + }: { + options: AlertExecutorOptions; + esClient: ElasticsearchClient; + dynamicSettings: DynamicSettings; + savedObjectsClient: SavedObjectsClientContract; + }) => Promise; } export const uptimeAlertWrapper = (uptimeAlert: UptimeAlertType) => ({ @@ -23,13 +27,13 @@ export const uptimeAlertWrapper = (uptimeAlert: UptimeAlertType) => ({ producer: 'uptime', executor: async (options: AlertExecutorOptions) => { const { - services: { callCluster: callES, scopedClusterClient }, + services: { scopedClusterClient: esClient, savedObjectsClient }, } = options; const dynamicSettings = await savedObjectsAdapter.getUptimeDynamicSettings( options.services.savedObjectsClient ); - return uptimeAlert.executor(options, callES, scopedClusterClient, dynamicSettings); + return uptimeAlert.executor({ options, esClient, dynamicSettings, savedObjectsClient }); }, }); diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/__snapshots__/get_monitor_charts.test.ts.snap b/x-pack/plugins/uptime/server/lib/requests/__tests__/__snapshots__/get_monitor_charts.test.ts.snap index 97b97f8440758..6ab55c2afddda 100644 --- a/x-pack/plugins/uptime/server/lib/requests/__tests__/__snapshots__/get_monitor_charts.test.ts.snap +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/__snapshots__/get_monitor_charts.test.ts.snap @@ -2,7 +2,6 @@ exports[`ElasticsearchMonitorsAdapter getMonitorChartsData will provide expected filters 1`] = ` Array [ - "search", Object { "body": Object { "aggs": Object { @@ -26,9 +25,6 @@ Array [ "buckets": 25, "field": "@timestamp", }, - "date_histogram": Object { - "fixed_interval": "36000ms", - }, }, }, "query": Object { diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_certs.test.ts b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_certs.test.ts index 4faaed53bebf2..c0b94b19b7582 100644 --- a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_certs.test.ts +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_certs.test.ts @@ -6,10 +6,10 @@ import { getCerts } from '../get_certs'; import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../../common/constants'; +import { elasticsearchServiceMock } from '../../../../../../../src/core/server/mocks'; describe('getCerts', () => { let mockHits: any; - let mockCallES: jest.Mock; beforeEach(() => { mockHits = [ @@ -79,17 +79,20 @@ describe('getCerts', () => { }, }, ]; - mockCallES = jest.fn(); - mockCallES.mockImplementation(() => ({ - hits: { - hits: mockHits, - }, - })); }); it('parses query result and returns expected values', async () => { + const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); + mockEsClient.search.mockResolvedValueOnce({ + body: { + hits: { + hits: mockHits, + }, + }, + } as any); + const result = await getCerts({ - callES: mockCallES, + callES: mockEsClient, dynamicSettings: { heartbeatIndices: 'heartbeat*', certAgeThreshold: DYNAMIC_SETTINGS_DEFAULTS.certAgeThreshold, @@ -126,10 +129,9 @@ describe('getCerts', () => { "total": 0, } `); - expect(mockCallES.mock.calls).toMatchInlineSnapshot(` + expect(mockEsClient.search.mock.calls).toMatchInlineSnapshot(` Array [ Array [ - "search", Object { "body": Object { "_source": Array [ diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_latest_monitor.test.ts b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_latest_monitor.test.ts index bd353b62df828..9503174ed104c 100644 --- a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_latest_monitor.test.ts +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_latest_monitor.test.ts @@ -6,6 +6,7 @@ import { getLatestMonitor } from '../get_latest_monitor'; import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../../common/constants'; +import { elasticsearchServiceMock } from '../../../../../../../src/core/server/mocks'; describe('getLatestMonitor', () => { let expectedGetLatestSearchParams: any; @@ -44,29 +45,33 @@ describe('getLatestMonitor', () => { }, }; mockEsSearchResult = { - hits: { - hits: [ - { - _id: 'fejwio32', - _source: { - '@timestamp': '123456', - monitor: { - duration: { - us: 12345, + body: { + hits: { + hits: [ + { + _id: 'fejwio32', + _source: { + '@timestamp': '123456', + monitor: { + duration: { + us: 12345, + }, + id: 'testMonitor', + status: 'down', + type: 'http', }, - id: 'testMonitor', - status: 'down', - type: 'http', }, }, - }, - ], + ], + }, }, }; }); it('returns data in expected shape', async () => { - const mockEsClient = jest.fn(async (_request: any, _params: any) => mockEsSearchResult); + const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); + mockEsClient.search.mockResolvedValueOnce(mockEsSearchResult); + const result = await getLatestMonitor({ callES: mockEsClient, dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, @@ -94,6 +99,6 @@ describe('getLatestMonitor', () => { expect(result.timestamp).toBe('123456'); expect(result.monitor).not.toBeFalsy(); expect(result?.monitor?.id).toBe('testMonitor'); - expect(mockEsClient).toHaveBeenCalledWith('search', expectedGetLatestSearchParams); + expect(mockEsClient.search).toHaveBeenCalledWith(expectedGetLatestSearchParams); }); }); diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_availability.test.ts b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_availability.test.ts index 015d9a4925f3e..e8df65d410167 100644 --- a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_availability.test.ts +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_availability.test.ts @@ -72,7 +72,7 @@ const genBucketItem = ({ describe('monitor availability', () => { describe('getMonitorAvailability', () => { it('applies bool filters to params', async () => { - const [callES, esMock] = setupMockEsCompositeQuery< + const esMock = setupMockEsCompositeQuery< AvailabilityKey, GetMonitorAvailabilityResult, AvailabilityDoc @@ -109,16 +109,15 @@ describe('monitor availability', () => { } }`; await getMonitorAvailability({ - callES, + callES: esMock, dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, filters: exampleFilter, range: 2, rangeUnit: 'w', threshold: '54', }); - expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1); - const [method, params] = esMock.callAsCurrentUser.mock.calls[0]; - expect(method).toEqual('search'); + expect(esMock.search).toHaveBeenCalledTimes(1); + const [params] = esMock.search.mock.calls[0]; expect(params).toMatchInlineSnapshot(` Object { "body": Object { @@ -245,7 +244,7 @@ describe('monitor availability', () => { }); it('fetches a single page of results', async () => { - const [callES, esMock] = setupMockEsCompositeQuery< + const esMock = setupMockEsCompositeQuery< AvailabilityKey, GetMonitorAvailabilityResult, AvailabilityDoc @@ -288,13 +287,12 @@ describe('monitor availability', () => { threshold: '69', }; const result = await getMonitorAvailability({ - callES, + callES: esMock, dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, ...clientParameters, }); - expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1); - const [method, params] = esMock.callAsCurrentUser.mock.calls[0]; - expect(method).toEqual('search'); + expect(esMock.search).toHaveBeenCalledTimes(1); + const [params] = esMock.search.mock.calls[0]; expect(params).toMatchInlineSnapshot(` Object { "body": Object { @@ -458,7 +456,7 @@ describe('monitor availability', () => { }); it('fetches multiple pages', async () => { - const [callES, esMock] = setupMockEsCompositeQuery< + const esMock = setupMockEsCompositeQuery< AvailabilityKey, GetMonitorAvailabilityResult, AvailabilityDoc @@ -512,7 +510,7 @@ describe('monitor availability', () => { genBucketItem ); const result = await getMonitorAvailability({ - callES, + callES: esMock, dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, range: 3, rangeUnit: 'M', @@ -606,9 +604,8 @@ describe('monitor availability', () => { }, ] `); - const [method, params] = esMock.callAsCurrentUser.mock.calls[0]; - expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(2); - expect(method).toEqual('search'); + const [params] = esMock.search.mock.calls[0]; + expect(esMock.search).toHaveBeenCalledTimes(2); expect(params).toMatchInlineSnapshot(` Object { "body": Object { @@ -701,9 +698,9 @@ describe('monitor availability', () => { "index": "heartbeat-8*", } `); - expect(esMock.callAsCurrentUser.mock.calls[1]).toMatchInlineSnapshot(` + + expect(esMock.search.mock.calls[1]).toMatchInlineSnapshot(` Array [ - "search", Object { "body": Object { "aggs": Object { @@ -803,7 +800,7 @@ describe('monitor availability', () => { }); it('does not overwrite filters', async () => { - const [callES, esMock] = setupMockEsCompositeQuery< + const esMock = setupMockEsCompositeQuery< AvailabilityKey, GetMonitorAvailabilityResult, AvailabilityDoc @@ -816,14 +813,14 @@ describe('monitor availability', () => { genBucketItem ); await getMonitorAvailability({ - callES, + callES: esMock, dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, range: 3, rangeUnit: 's', threshold: '99', filters: JSON.stringify({ bool: { filter: [{ term: { 'monitor.id': 'foo' } }] } }), }); - const [, params] = esMock.callAsCurrentUser.mock.calls[0]; + const [params] = esMock.search.mock.calls[0]; expect(params).toMatchInlineSnapshot(` Object { "body": Object { diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_charts.test.ts b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_charts.test.ts index 2ebe670bc43c1..9edd3e2e160d2 100644 --- a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_charts.test.ts +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_charts.test.ts @@ -8,37 +8,37 @@ import { set } from '@elastic/safer-lodash-set'; import mockChartsData from './monitor_charts_mock.json'; import { getMonitorDurationChart } from '../get_monitor_duration'; import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../../common/constants'; +import { elasticsearchServiceMock } from '../../../../../../../src/core/server/mocks'; describe('ElasticsearchMonitorsAdapter', () => { it('getMonitorChartsData will provide expected filters', async () => { expect.assertions(2); - const searchMock = jest.fn(); - const search = searchMock.bind({}); + const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); await getMonitorDurationChart({ - callES: search, + callES: mockEsClient, dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, monitorId: 'fooID', dateStart: 'now-15m', dateEnd: 'now', }); - expect(searchMock).toHaveBeenCalledTimes(1); + expect(mockEsClient.search).toHaveBeenCalledTimes(1); // protect against possible rounding errors polluting the snapshot comparison set( - searchMock.mock.calls[0][1], + mockEsClient.search.mock.calls[0], 'body.aggs.timeseries.date_histogram.fixed_interval', '36000ms' ); - expect(searchMock.mock.calls[0]).toMatchSnapshot(); + expect(mockEsClient.search.mock.calls[0]).toMatchSnapshot(); }); it('inserts empty buckets for missing data', async () => { - const searchMock = jest.fn(); - searchMock.mockReturnValue(mockChartsData); - const search = searchMock.bind({}); + const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); + mockEsClient.search.mockResolvedValueOnce(mockChartsData as any); + expect( await getMonitorDurationChart({ - callES: search, + callES: mockEsClient, dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, monitorId: 'id', dateStart: 'now-15m', diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts index e61d736e37106..949bc39f07259 100644 --- a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts @@ -42,7 +42,7 @@ const genBucketItem = ({ describe('getMonitorStatus', () => { it('applies bool filters to params', async () => { - const [callES, esMock] = setupMockEsCompositeQuery( + const esMock = setupMockEsCompositeQuery( [], genBucketItem ); @@ -78,7 +78,7 @@ describe('getMonitorStatus', () => { }, }; await getMonitorStatus({ - callES, + callES: esMock, dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, filters: exampleFilter, locations: [], @@ -88,9 +88,8 @@ describe('getMonitorStatus', () => { to: 'now-1m', }, }); - expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1); - const [method, params] = esMock.callAsCurrentUser.mock.calls[0]; - expect(method).toEqual('search'); + expect(esMock.search).toHaveBeenCalledTimes(1); + const [params] = esMock.search.mock.calls[0]; expect(params).toMatchInlineSnapshot(` Object { "body": Object { @@ -190,12 +189,12 @@ describe('getMonitorStatus', () => { }); it('applies locations to params', async () => { - const [callES, esMock] = setupMockEsCompositeQuery( + const esMock = setupMockEsCompositeQuery( [], genBucketItem ); await getMonitorStatus({ - callES, + callES: esMock, dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, locations: ['fairbanks', 'harrisburg'], numTimes: 1, @@ -204,9 +203,8 @@ describe('getMonitorStatus', () => { to: 'now', }, }); - expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1); - const [method, params] = esMock.callAsCurrentUser.mock.calls[0]; - expect(method).toEqual('search'); + expect(esMock.search).toHaveBeenCalledTimes(1); + const [params] = esMock.search.mock.calls[0]; expect(params).toMatchInlineSnapshot(` Object { "body": Object { @@ -291,7 +289,7 @@ describe('getMonitorStatus', () => { }); it('properly assigns filters for complex kuery filters', async () => { - const [callES, esMock] = setupMockEsCompositeQuery( + const esMock = setupMockEsCompositeQuery( [{ bucketCriteria: [] }], genBucketItem ); @@ -353,12 +351,12 @@ describe('getMonitorStatus', () => { }, }; await getMonitorStatus({ - callES, + callES: esMock, dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, ...clientParameters, }); - expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1); - const [, params] = esMock.callAsCurrentUser.mock.calls[0]; + expect(esMock.search).toHaveBeenCalledTimes(1); + const [params] = esMock.search.mock.calls[0]; expect(params).toMatchInlineSnapshot(` Object { "body": Object { @@ -476,7 +474,7 @@ describe('getMonitorStatus', () => { }); it('properly assigns filters for complex kuery filters object', async () => { - const [callES, esMock] = setupMockEsCompositeQuery( + const esMock = setupMockEsCompositeQuery( [{ bucketCriteria: [] }], genBucketItem ); @@ -498,12 +496,12 @@ describe('getMonitorStatus', () => { }, }; await getMonitorStatus({ - callES, + callES: esMock, dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, ...clientParameters, }); - expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1); - const [, params] = esMock.callAsCurrentUser.mock.calls[0]; + expect(esMock.search).toHaveBeenCalledTimes(1); + const [params] = esMock.search.mock.calls[0]; expect(params).toMatchInlineSnapshot(` Object { "body": Object { @@ -581,7 +579,7 @@ describe('getMonitorStatus', () => { }); it('fetches single page of results', async () => { - const [callES, esMock] = setupMockEsCompositeQuery( + const esMock = setupMockEsCompositeQuery( [ { bucketCriteria: [ @@ -618,13 +616,12 @@ describe('getMonitorStatus', () => { }, }; const result = await getMonitorStatus({ - callES, + callES: esMock, dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, ...clientParameters, }); - expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1); - const [method, params] = esMock.callAsCurrentUser.mock.calls[0]; - expect(method).toEqual('search'); + expect(esMock.search).toHaveBeenCalledTimes(1); + const [params] = esMock.search.mock.calls[0]; expect(params).toMatchInlineSnapshot(` Object { "body": Object { @@ -792,12 +789,12 @@ describe('getMonitorStatus', () => { ], }, ]; - const [callES] = setupMockEsCompositeQuery( + const esMock = setupMockEsCompositeQuery( criteria, genBucketItem ); const result = await getMonitorStatus({ - callES, + callES: esMock, dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, locations: [], numTimes: 5, diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_ping_histogram.test.ts b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_ping_histogram.test.ts index ac940ffb6676f..86e5f2876ca28 100644 --- a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_ping_histogram.test.ts +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_ping_histogram.test.ts @@ -6,6 +6,7 @@ import { getPingHistogram } from '../get_ping_histogram'; import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../../common/constants'; +import { elasticsearchServiceMock } from '../../../../../../../src/core/server/mocks'; describe('getPingHistogram', () => { const standardMockResponse: any = { @@ -37,25 +38,28 @@ describe('getPingHistogram', () => { it.skip('returns a single bucket if array has 1', async () => { expect.assertions(2); - const mockEsClient = jest.fn(); - mockEsClient.mockReturnValue({ - aggregations: { - timeseries: { - buckets: [ - { - key: 1, - up: { - doc_count: 2, + const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); + + mockEsClient.search.mockResolvedValueOnce({ + body: { + aggregations: { + timeseries: { + buckets: [ + { + key: 1, + up: { + doc_count: 2, + }, + down: { + doc_count: 1, + }, }, - down: { - doc_count: 1, - }, - }, - ], - interval: '10s', + ], + interval: '10s', + }, }, }, - }); + } as any); const result = await getPingHistogram({ callES: mockEsClient, @@ -64,16 +68,20 @@ describe('getPingHistogram', () => { to: 'now', }); - expect(mockEsClient).toHaveBeenCalledTimes(1); + expect(mockEsClient.search).toHaveBeenCalledTimes(1); expect(result).toMatchSnapshot(); }); it('returns expected result for no status filter', async () => { expect.assertions(2); - const mockEsClient = jest.fn(); + + const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); standardMockResponse.aggregations.timeseries.interval = '1m'; - mockEsClient.mockReturnValue(standardMockResponse); + + mockEsClient.search.mockResolvedValueOnce({ + body: standardMockResponse, + } as any); const result = await getPingHistogram({ callES: mockEsClient, @@ -83,50 +91,53 @@ describe('getPingHistogram', () => { filters: '', }); - expect(mockEsClient).toHaveBeenCalledTimes(1); + expect(mockEsClient.search).toHaveBeenCalledTimes(1); expect(result).toMatchSnapshot(); }); it('handles status + additional user queries', async () => { expect.assertions(2); - const mockEsClient = jest.fn(); - - mockEsClient.mockReturnValue({ - aggregations: { - timeseries: { - buckets: [ - { - key: 1, - up: { - doc_count: 2, - }, - down: { - doc_count: 1, - }, - }, - { - key: 2, - up: { - doc_count: 2, - }, - down: { - doc_count: 2, + + const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); + + mockEsClient.search.mockResolvedValueOnce({ + body: { + aggregations: { + timeseries: { + buckets: [ + { + key: 1, + up: { + doc_count: 2, + }, + down: { + doc_count: 1, + }, }, - }, - { - key: 3, - up: { - doc_count: 3, + { + key: 2, + up: { + doc_count: 2, + }, + down: { + doc_count: 2, + }, }, - down: { - doc_count: 1, + { + key: 3, + up: { + doc_count: 3, + }, + down: { + doc_count: 1, + }, }, - }, - ], - interval: '1h', + ], + interval: '1h', + }, }, }, - }); + } as any); const searchFilter = { bool: { @@ -146,50 +157,52 @@ describe('getPingHistogram', () => { monitorId: undefined, }); - expect(mockEsClient).toHaveBeenCalledTimes(1); + expect(mockEsClient.search).toHaveBeenCalledTimes(1); expect(result).toMatchSnapshot(); }); it('handles simple_text_query without issues', async () => { expect.assertions(2); - const mockEsClient = jest.fn(); - - mockEsClient.mockReturnValue({ - aggregations: { - timeseries: { - buckets: [ - { - key: 1, - up: { - doc_count: 2, + const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); + + mockEsClient.search.mockResolvedValueOnce({ + body: { + aggregations: { + timeseries: { + buckets: [ + { + key: 1, + up: { + doc_count: 2, + }, + down: { + doc_count: 1, + }, }, - down: { - doc_count: 1, + { + key: 2, + up: { + doc_count: 1, + }, + down: { + doc_count: 2, + }, }, - }, - { - key: 2, - up: { - doc_count: 1, - }, - down: { - doc_count: 2, + { + key: 3, + up: { + doc_count: 3, + }, + down: { + doc_count: 1, + }, }, - }, - { - key: 3, - up: { - doc_count: 3, - }, - down: { - doc_count: 1, - }, - }, - ], - interval: '1m', + ], + interval: '1m', + }, }, }, - }); + } as any); const filters = `{"bool":{"must":[{"simple_query_string":{"query":"http"}}]}}`; const result = await getPingHistogram({ @@ -200,7 +213,7 @@ describe('getPingHistogram', () => { filters, }); - expect(mockEsClient).toHaveBeenCalledTimes(1); + expect(mockEsClient.search).toHaveBeenCalledTimes(1); expect(result).toMatchSnapshot(); }); }); diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_pings.test.ts b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_pings.test.ts index cb84cc2eb05b6..f313cce9f758b 100644 --- a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_pings.test.ts +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_pings.test.ts @@ -7,6 +7,7 @@ import { getPings } from '../get_pings'; import { set } from '@elastic/safer-lodash-set'; import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../../common/constants'; +import { elasticsearchServiceMock } from '../../../../../../../src/core/server/mocks'; describe('getAll', () => { let mockEsSearchResult: any; @@ -49,15 +50,17 @@ describe('getAll', () => { }, ]; mockEsSearchResult = { - hits: { - total: { - value: mockHits.length, + body: { + hits: { + total: { + value: mockHits.length, + }, + hits: mockHits, }, - hits: mockHits, - }, - aggregations: { - locations: { - buckets: [{ key: 'foo' }], + aggregations: { + locations: { + buckets: [{ key: 'foo' }], + }, }, }, }; @@ -84,8 +87,9 @@ describe('getAll', () => { }); it('returns data in the appropriate shape', async () => { - const mockEsClient = jest.fn(); - mockEsClient.mockReturnValue(mockEsSearchResult); + const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); + + mockEsClient.search.mockResolvedValueOnce(mockEsSearchResult); const result = await getPings({ callES: mockEsClient, dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, @@ -102,12 +106,12 @@ describe('getAll', () => { expect(pings[0].timestamp).toBe('2018-10-30T18:51:59.792Z'); expect(pings[1].timestamp).toBe('2018-10-30T18:53:59.792Z'); expect(pings[2].timestamp).toBe('2018-10-30T18:55:59.792Z'); - expect(mockEsClient).toHaveBeenCalledTimes(1); + expect(mockEsClient.search).toHaveBeenCalledTimes(1); }); it('creates appropriate sort and size parameters', async () => { - const mockEsClient = jest.fn(); - mockEsClient.mockReturnValue(mockEsSearchResult); + const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); + mockEsClient.search.mockResolvedValueOnce(mockEsSearchResult); await getPings({ callES: mockEsClient, dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, @@ -117,10 +121,9 @@ describe('getAll', () => { }); set(expectedGetAllParams, 'body.sort[0]', { timestamp: { order: 'asc' } }); - expect(mockEsClient).toHaveBeenCalledTimes(1); - expect(mockEsClient.mock.calls[0]).toMatchInlineSnapshot(` + expect(mockEsClient.search).toHaveBeenCalledTimes(1); + expect(mockEsClient.search.mock.calls[0]).toMatchInlineSnapshot(` Array [ - "search", Object { "body": Object { "aggregations": Object { @@ -186,8 +189,8 @@ describe('getAll', () => { }); it('omits the sort param when no sort passed', async () => { - const mockEsClient = jest.fn(); - mockEsClient.mockReturnValue(mockEsSearchResult); + const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); + mockEsClient.search.mockResolvedValueOnce(mockEsSearchResult); await getPings({ callES: mockEsClient, dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, @@ -195,10 +198,9 @@ describe('getAll', () => { size: 12, }); - expect(mockEsClient).toHaveBeenCalledTimes(1); - expect(mockEsClient.mock.calls[0]).toMatchInlineSnapshot(` + expect(mockEsClient.search).toHaveBeenCalledTimes(1); + expect(mockEsClient.search.mock.calls[0]).toMatchInlineSnapshot(` Array [ - "search", Object { "body": Object { "aggregations": Object { @@ -264,8 +266,8 @@ describe('getAll', () => { }); it('omits the size param when no size passed', async () => { - const mockEsClient = jest.fn(); - mockEsClient.mockReturnValue(mockEsSearchResult); + const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); + mockEsClient.search.mockResolvedValueOnce(mockEsSearchResult); await getPings({ callES: mockEsClient, dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, @@ -273,10 +275,9 @@ describe('getAll', () => { sort: 'desc', }); - expect(mockEsClient).toHaveBeenCalledTimes(1); - expect(mockEsClient.mock.calls[0]).toMatchInlineSnapshot(` + expect(mockEsClient.search).toHaveBeenCalledTimes(1); + expect(mockEsClient.search.mock.calls[0]).toMatchInlineSnapshot(` Array [ - "search", Object { "body": Object { "aggregations": Object { @@ -342,8 +343,8 @@ describe('getAll', () => { }); it('adds a filter for monitor ID', async () => { - const mockEsClient = jest.fn(); - mockEsClient.mockReturnValue(mockEsSearchResult); + const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); + mockEsClient.search.mockResolvedValueOnce(mockEsSearchResult); await getPings({ callES: mockEsClient, dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, @@ -351,10 +352,9 @@ describe('getAll', () => { monitorId: 'testmonitorid', }); - expect(mockEsClient).toHaveBeenCalledTimes(1); - expect(mockEsClient.mock.calls[0]).toMatchInlineSnapshot(` + expect(mockEsClient.search).toHaveBeenCalledTimes(1); + expect(mockEsClient.search.mock.calls[0]).toMatchInlineSnapshot(` Array [ - "search", Object { "body": Object { "aggregations": Object { @@ -425,8 +425,8 @@ describe('getAll', () => { }); it('adds a filter for monitor status', async () => { - const mockEsClient = jest.fn(); - mockEsClient.mockReturnValue(mockEsSearchResult); + const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); + mockEsClient.search.mockResolvedValueOnce(mockEsSearchResult); await getPings({ callES: mockEsClient, dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, @@ -434,10 +434,9 @@ describe('getAll', () => { status: 'down', }); - expect(mockEsClient).toHaveBeenCalledTimes(1); - expect(mockEsClient.mock.calls[0]).toMatchInlineSnapshot(` + expect(mockEsClient.search).toHaveBeenCalledTimes(1); + expect(mockEsClient.search.mock.calls[0]).toMatchInlineSnapshot(` Array [ - "search", Object { "body": Object { "aggregations": Object { diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/helper.ts b/x-pack/plugins/uptime/server/lib/requests/__tests__/helper.ts index 878569b5d390f..4ebc9b2da7855 100644 --- a/x-pack/plugins/uptime/server/lib/requests/__tests__/helper.ts +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/helper.ts @@ -4,16 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyScopedClusterClient } from 'src/core/server'; import { elasticsearchServiceMock } from '../../../../../../../src/core/server/mocks'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ElasticsearchClientMock } from '../../../../../../../src/core/server/elasticsearch/client/mocks'; + export interface MultiPageCriteria { after_key?: K; bucketCriteria: T[]; } -export type MockCallES = (method: any, params: any) => Promise; - /** * This utility function will set up a mock ES client, and store subsequent calls. It is designed * to let callers easily simulate an arbitrary series of chained composite aggregation calls by supplying @@ -30,8 +30,8 @@ export type MockCallES = (method: any, params: any) => Promise; export const setupMockEsCompositeQuery = ( criteria: Array>, genBucketItem: (criteria: C) => I -): [MockCallES, jest.Mocked>] => { - const esMock = elasticsearchServiceMock.createLegacyScopedClusterClient(); +): ElasticsearchClientMock => { + const esMock = elasticsearchServiceMock.createElasticsearchClient(); // eslint-disable-next-line @typescript-eslint/naming-convention criteria.forEach(({ after_key, bucketCriteria }) => { @@ -43,8 +43,14 @@ export const setupMockEsCompositeQuery = ( }, }, }; - esMock.callAsCurrentUser.mockResolvedValueOnce(mockResponse); + esMock.search.mockResolvedValueOnce({ + body: mockResponse, + statusCode: 200, + headers: {}, + warnings: [], + meta: {} as any, + }); }); - return [(method: any, params: any) => esMock.callAsCurrentUser(method, params), esMock]; + return esMock; }; diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/monitor_charts_mock.json b/x-pack/plugins/uptime/server/lib/requests/__tests__/monitor_charts_mock.json index c62e862a9af89..9fbfdb98d7fa4 100644 --- a/x-pack/plugins/uptime/server/lib/requests/__tests__/monitor_charts_mock.json +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/monitor_charts_mock.json @@ -1,146 +1,318 @@ { - "took": 40, - "timed_out": false, - "_shards": { - "total": 1, - "successful": 1, - "skipped": 0, - "failed": 0 - }, - "aggregations": { - "timeseries": { - "buckets": [ - { - "key": 1568411568000, - "doc_count": 4, - "location": { - "buckets": [ - { "key": "us-east-2", "duration": { "avg": 4658759 } }, - { "key": "us-west-4", "duration": { "avg": 8678399.5 } } - ] + "body": { + "took": 40, + "timed_out": false, + "_shards": { + "total": 1, + "successful": 1, + "skipped": 0, + "failed": 0 + }, + "aggregations": { + "timeseries": { + "buckets": [ + { + "key": 1568411568000, + "doc_count": 4, + "location": { + "buckets": [ + { + "key": "us-east-2", + "duration": { + "avg": 4658759 + } + }, + { + "key": "us-west-4", + "duration": { + "avg": 8678399.5 + } + } + ] + } + }, + { + "key": 1568411604000, + "doc_count": 0, + "location": { + "buckets": [] + } + }, + { + "key": 1568411640000, + "doc_count": 8, + "location": { + "buckets": [ + { + "key": "us-east-2", + "duration": { + "avg": 481780 + } + }, + { + "key": "us-west-4", + "duration": { + "avg": 685056.5 + } + } + ] + } + }, + { + "key": 1568411784000, + "doc_count": 8, + "location": { + "buckets": [ + { + "key": "us-east-2", + "duration": { + "avg": 469206.5 + } + }, + { + "key": "us-west-4", + "duration": { + "avg": 261406.5 + } + } + ] + } + }, + { + "key": 1568411820000, + "doc_count": 0, + "location": { + "buckets": [] + } + }, + { + "key": 1568411856000, + "doc_count": 0, + "location": { + "buckets": [] + } + }, + { + "key": 1568411892000, + "doc_count": 0, + "location": { + "buckets": [] + } + }, + { + "key": 1568411928000, + "doc_count": 4, + "location": { + "buckets": [ + { + "key": "us-west-4", + "duration": { + "avg": 1999309.6666667 + } + }, + { + "key": "us-east-2", + "duration": { + "avg": 645563 + } + } + ] + } + }, + { + "key": 1568411964000, + "doc_count": 7, + "location": { + "buckets": [ + { + "key": "us-west-4", + "duration": { + "avg": 2499799.25 + } + }, + { + "key": "us-east-2", + "duration": { + "avg": 1513896.6666667 + } + } + ] + } + }, + { + "key": 1568412036000, + "doc_count": 5, + "location": { + "buckets": [ + { + "key": "us-west-4", + "duration": { + "avg": 1876155.3333333 + } + }, + { + "key": "us-east-2", + "duration": { + "avg": 1511409 + } + } + ] + } + }, + { + "key": 1568412072000, + "doc_count": 4, + "location": { + "buckets": [ + { + "key": "us-west-4", + "duration": { + "avg": 1490845.75 + } + } + ] + } + }, + { + "key": 1568412108000, + "doc_count": 3, + "location": { + "buckets": [ + { + "key": "us-west-4", + "duration": { + "avg": 2365962.6666667 + } + } + ] + } + }, + { + "key": 1568412144000, + "doc_count": 4, + "location": { + "buckets": [ + { + "key": "us-west-4", + "duration": { + "avg": 1788901.25 + } + } + ] + } + }, + { + "key": 1568412180000, + "doc_count": 4, + "location": { + "buckets": [ + { + "key": "us-west-4", + "duration": { + "avg": 1773177.5 + } + } + ] + } + }, + { + "key": 1568412216000, + "doc_count": 3, + "location": { + "buckets": [ + { + "key": "us-west-4", + "duration": { + "avg": 3086220.3333333 + } + } + ] + } + }, + { + "key": 1568412252000, + "doc_count": 1, + "location": { + "buckets": [ + { + "key": "us-west-4", + "duration": { + "avg": 1020528 + } + } + ] + } + }, + { + "key": 1568412288000, + "doc_count": 3, + "location": { + "buckets": [ + { + "key": "us-west-4", + "duration": { + "avg": 1643963.3333333 + } + } + ] + } + }, + { + "key": 1568412324000, + "doc_count": 8, + "location": { + "buckets": [ + { + "key": "us-east-2", + "duration": { + "avg": 1804116 + } + }, + { + "key": "us-west-4", + "duration": { + "avg": 1799630 + } + } + ] + } + }, + { + "key": 1568412432000, + "doc_count": 8, + "location": { + "buckets": [ + { + "key": "us-east-2", + "duration": { + "avg": 1972483.25 + } + }, + { + "key": "us-west-4", + "duration": { + "avg": 1543307.5 + } + } + ] + } + }, + { + "key": 1568412468000, + "doc_count": 1, + "location": { + "buckets": [ + { + "key": "us-east-2", + "duration": { + "avg": 1020490 + } + } + ] + } } - }, - { "key": 1568411604000, "doc_count": 0, "location": { "buckets": [] } }, - { - "key": 1568411640000, - "doc_count": 8, - "location": { - "buckets": [ - { "key": "us-east-2", "duration": { "avg": 481780 } }, - { "key": "us-west-4", "duration": { "avg": 685056.5 } } - ] - } - }, - { - "key": 1568411784000, - "doc_count": 8, - "location": { - "buckets": [ - { "key": "us-east-2", "duration": { "avg": 469206.5 } }, - { "key": "us-west-4", "duration": { "avg": 261406.5 } } - ] - } - }, - { "key": 1568411820000, "doc_count": 0, "location": { "buckets": [] } }, - { "key": 1568411856000, "doc_count": 0, "location": { "buckets": [] } }, - { "key": 1568411892000, "doc_count": 0, "location": { "buckets": [] } }, - { - "key": 1568411928000, - "doc_count": 4, - "location": { - "buckets": [ - { "key": "us-west-4", "duration": { "avg": 1999309.6666667 } }, - { "key": "us-east-2", "duration": { "avg": 645563 } } - ] - } - }, - { - "key": 1568411964000, - "doc_count": 7, - "location": { - "buckets": [ - { "key": "us-west-4", "duration": { "avg": 2499799.25 } }, - { "key": "us-east-2", "duration": { "avg": 1513896.6666667 } } - ] - } - }, - { - "key": 1568412036000, - "doc_count": 5, - "location": { - "buckets": [ - { "key": "us-west-4", "duration": { "avg": 1876155.3333333 } }, - { "key": "us-east-2", "duration": { "avg": 1511409 } } - ] - } - }, - { - "key": 1568412072000, - "doc_count": 4, - "location": { "buckets": [{ "key": "us-west-4", "duration": { "avg": 1490845.75 } }] } - }, - { - "key": 1568412108000, - "doc_count": 3, - "location": { - "buckets": [{ "key": "us-west-4", "duration": { "avg": 2365962.6666667 } }] - } - }, - { - "key": 1568412144000, - "doc_count": 4, - "location": { "buckets": [{ "key": "us-west-4", "duration": { "avg": 1788901.25 } }] } - }, - { - "key": 1568412180000, - "doc_count": 4, - "location": { "buckets": [{ "key": "us-west-4", "duration": { "avg": 1773177.5 } }] } - }, - { - "key": 1568412216000, - "doc_count": 3, - "location": { - "buckets": [{ "key": "us-west-4", "duration": { "avg": 3086220.3333333 } }] - } - }, - { - "key": 1568412252000, - "doc_count": 1, - "location": { "buckets": [{ "key": "us-west-4", "duration": { "avg": 1020528 } }] } - }, - { - "key": 1568412288000, - "doc_count": 3, - "location": { - "buckets": [{ "key": "us-west-4", "duration": { "avg": 1643963.3333333 } }] - } - }, - { - "key": 1568412324000, - "doc_count": 8, - "location": { - "buckets": [ - { "key": "us-east-2", "duration": { "avg": 1804116 } }, - { "key": "us-west-4", "duration": { "avg": 1799630 } } - ] - } - }, - { - "key": 1568412432000, - "doc_count": 8, - "location": { - "buckets": [ - { "key": "us-east-2", "duration": { "avg": 1972483.25 } }, - { "key": "us-west-4", "duration": { "avg": 1543307.5 } } - ] - } - }, - { - "key": 1568412468000, - "doc_count": 1, - "location": { "buckets": [{ "key": "us-east-2", "duration": { "avg": 1020490 } }] } - } - ] + ] + } } } } diff --git a/x-pack/plugins/uptime/server/lib/requests/get_certs.ts b/x-pack/plugins/uptime/server/lib/requests/get_certs.ts index 4793d420cbfd8..0836cb039b215 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_certs.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_certs.ts @@ -145,7 +145,7 @@ export const getCerts: UMElasticsearchQueryFn = asyn } // console.log(JSON.stringify(params, null, 2)); - const result = await callES('search', params); + const { body: result } = await callES.search(params); const certs = (result?.hits?.hits ?? []).map((hit: any) => { const { diff --git a/x-pack/plugins/uptime/server/lib/requests/get_filter_bar.ts b/x-pack/plugins/uptime/server/lib/requests/get_filter_bar.ts index e89b457eccf32..c3295d6dd9c8f 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_filter_bar.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_filter_bar.ts @@ -93,6 +93,8 @@ export const getFilterBar: UMElasticsearchQueryFn = async ({ esClient, dynamicSettings }) => { +export const getUptimeIndexPattern = async ({ + esClient, + dynamicSettings, +}: { + esClient: ElasticsearchClient; + dynamicSettings: DynamicSettings; +}): Promise => { const indexPatternsFetcher = new IndexPatternsFetcher(esClient); // Since `getDynamicIndexPattern` is called in setup_request (and thus by every endpoint) @@ -28,12 +31,10 @@ export const getUptimeIndexPattern: UMElasticsearchQueryFn< pattern: dynamicSettings.heartbeatIndices, }); - const indexPattern: IndexPatternTitleAndFields = { + return { fields, title: dynamicSettings.heartbeatIndices, }; - - return indexPattern; } catch (e) { const notExists = e.output?.statusCode === 404; if (notExists) { diff --git a/x-pack/plugins/uptime/server/lib/requests/get_index_status.ts b/x-pack/plugins/uptime/server/lib/requests/get_index_status.ts index 7688f04f1acd9..061d002b010de 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_index_status.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_index_status.ts @@ -12,9 +12,11 @@ export const getIndexStatus: UMElasticsearchQueryFn<{}, StatesIndexStatus> = asy dynamicSettings, }) => { const { - _shards: { total }, - count, - } = await callES('count', { index: dynamicSettings.heartbeatIndices }); + body: { + _shards: { total }, + count, + }, + } = await callES.count({ index: dynamicSettings.heartbeatIndices }); return { indexExists: total > 0, docCount: count, diff --git a/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.ts b/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.ts index f726ef47915b8..bff3aaf1176df 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.ts @@ -42,7 +42,7 @@ export const getJourneyScreenshot: UMElasticsearchQueryFn< _source: ['synthetics.blob'], }, }; - const result = await callES('search', params); + const { body: result } = await callES.search(params); if (!Array.isArray(result?.hits?.hits) || result.hits.hits.length < 1) { return null; } diff --git a/x-pack/plugins/uptime/server/lib/requests/get_journey_steps.ts b/x-pack/plugins/uptime/server/lib/requests/get_journey_steps.ts index 9c139b2ce8588..f36815a747db3 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_journey_steps.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_journey_steps.ts @@ -42,7 +42,7 @@ export const getJourneySteps: UMElasticsearchQueryFn h?._source?.synthetics?.type === 'step/screenshot') .map((h: any) => h?._source?.synthetics?.step?.index); diff --git a/x-pack/plugins/uptime/server/lib/requests/get_latest_monitor.ts b/x-pack/plugins/uptime/server/lib/requests/get_latest_monitor.ts index d32b78bdc7139..f6562eaa42e90 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_latest_monitor.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_latest_monitor.ts @@ -57,7 +57,7 @@ export const getLatestMonitor: UMElasticsearchQueryFn { +const getMonitorAlerts = async ({ + callES, + dynamicSettings, + alertsClient, + monitorId, +}: { + callES: ElasticsearchClient; + dynamicSettings: any; + alertsClient: any; + monitorId: string; +}) => { const options: any = { page: 1, perPage: 500, @@ -70,13 +73,12 @@ const getMonitorAlerts = async ( const parsedFilters = await formatFilterString( dynamicSettings, callES, - esClient, currAlert.params.filters, currAlert.params.search ); esParams.body.query.bool = Object.assign({}, esParams.body.query.bool, parsedFilters?.bool); - const result = await callES('search', esParams); + const { body: result } = await callES.search(esParams); if (result.hits.total.value > 0) { monitorAlerts.push(currAlert); @@ -88,7 +90,7 @@ const getMonitorAlerts = async ( export const getMonitorDetails: UMElasticsearchQueryFn< GetMonitorDetailsParams, MonitorDetails -> = async ({ callES, esClient, dynamicSettings, monitorId, dateStart, dateEnd, alertsClient }) => { +> = async ({ callES, dynamicSettings, monitorId, dateStart, dateEnd, alertsClient }) => { const queryFilters: any = [ { range: { @@ -132,19 +134,19 @@ export const getMonitorDetails: UMElasticsearchQueryFn< }, }; - const result = await callES('search', params); + const { body: result } = await callES.search(params); const data = result.hits.hits[0]?._source; const monitorError: MonitorError | undefined = data?.error; const errorTimestamp: string | undefined = data?.['@timestamp']; - const monAlerts = await getMonitorAlerts( + const monAlerts = await getMonitorAlerts({ callES, - esClient, dynamicSettings, alertsClient, - monitorId - ); + monitorId, + }); + return { monitorId, error: monitorError, diff --git a/x-pack/plugins/uptime/server/lib/requests/get_monitor_duration.ts b/x-pack/plugins/uptime/server/lib/requests/get_monitor_duration.ts index 00ca1b5878329..77ae7570a96a8 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_monitor_duration.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_monitor_duration.ts @@ -59,7 +59,7 @@ export const getMonitorDurationChart: UMElasticsearchQueryFn< }, }; - const result = await callES('search', params); + const { body: result } = await callES.search(params); const dateHistogramBuckets: any[] = result?.aggregations?.timeseries?.buckets ?? []; diff --git a/x-pack/plugins/uptime/server/lib/requests/get_monitor_locations.ts b/x-pack/plugins/uptime/server/lib/requests/get_monitor_locations.ts index f52e965d488ea..b5183ca9ffb9f 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_monitor_locations.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_monitor_locations.ts @@ -88,7 +88,7 @@ export const getMonitorLocations: UMElasticsearchQueryFn< }, }; - const result = await callES('search', params); + const { body: result } = await callES.search(params); const locations = result?.aggregations?.location?.buckets ?? []; const getGeo = (locGeo: { name: string; location?: string }) => { diff --git a/x-pack/plugins/uptime/server/lib/requests/get_monitor_states.ts b/x-pack/plugins/uptime/server/lib/requests/get_monitor_states.ts index 3e49a32881f54..020fcf5331188 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_monitor_states.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_monitor_states.ts @@ -153,7 +153,7 @@ export const getHistogramForMonitors = async ( }; const result = await queryContext.search(params); - const histoBuckets: any[] = result.aggregations.histogram.buckets; + const histoBuckets: any[] = result.aggregations?.histogram.buckets ?? []; const simplified = histoBuckets.map((histoBucket: any): { timestamp: number; byId: any } => { const byId: { [key: string]: number } = {}; histoBucket.by_id.buckets.forEach((idBucket: any) => { diff --git a/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts b/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts index caf505610e991..06648d68969c1 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts @@ -133,7 +133,7 @@ export const getMonitorStatus: UMElasticsearchQueryFn< esParams.body.aggs.monitors.composite.after = afterKey; } - const result = await callES('search', esParams); + const { body: result } = await callES.search(esParams); afterKey = result?.aggregations?.monitors?.after_key; monitors = monitors.concat(result?.aggregations?.monitors?.buckets || []); diff --git a/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts b/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts index 5d8706e2fc5f1..4eb2d862cb702 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts @@ -76,7 +76,7 @@ export const getPingHistogram: UMElasticsearchQueryFn< }, }; - const result = await callES('search', params); + const { body: result } = await callES.search(params); const buckets: HistogramQueryResult[] = result?.aggregations?.timeseries?.buckets ?? []; const histogram = buckets.map((bucket) => { const x: number = bucket.key; diff --git a/x-pack/plugins/uptime/server/lib/requests/get_pings.ts b/x-pack/plugins/uptime/server/lib/requests/get_pings.ts index 03ec2d7343c9a..e72b16de3d66f 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_pings.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_pings.ts @@ -108,9 +108,11 @@ export const getPings: UMElasticsearchQueryFn = a } const { - hits: { hits, total }, - aggregations: aggs, - } = await callES('search', params); + body: { + hits: { hits, total }, + aggregations: aggs, + }, + } = await callES.search(params); const locations = aggs?.locations ?? { buckets: [{ key: 'N/A', doc_count: 0 }] }; diff --git a/x-pack/plugins/uptime/server/lib/requests/get_snapshot_counts.ts b/x-pack/plugins/uptime/server/lib/requests/get_snapshot_counts.ts index 92295a38cffb4..ac36585ff0939 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_snapshot_counts.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_snapshot_counts.ts @@ -39,7 +39,7 @@ export const getSnapshotCount: UMElasticsearchQueryFn => { - const res = await context.search({ + const { body: res } = await context.search({ index: context.heartbeatIndices, body: statusCountBody(await context.dateAndCustomFilters()), }); diff --git a/x-pack/plugins/uptime/server/lib/requests/search/find_potential_matches.ts b/x-pack/plugins/uptime/server/lib/requests/search/find_potential_matches.ts index 6c229cf30e165..38e7dabb19941 100644 --- a/x-pack/plugins/uptime/server/lib/requests/search/find_potential_matches.ts +++ b/x-pack/plugins/uptime/server/lib/requests/search/find_potential_matches.ts @@ -19,7 +19,7 @@ export const findPotentialMatches = async ( searchAfter: any, size: number ) => { - const queryResult = await query(queryContext, searchAfter, size); + const { body: queryResult } = await query(queryContext, searchAfter, size); const monitorIds: string[] = []; get(queryResult, 'aggregations.monitors.buckets', []).forEach((b: any) => { const monitorId = b.key.monitor_id; diff --git a/x-pack/plugins/uptime/server/lib/requests/search/query_context.ts b/x-pack/plugins/uptime/server/lib/requests/search/query_context.ts index 5d97e635f3e7d..96df8ea651c44 100644 --- a/x-pack/plugins/uptime/server/lib/requests/search/query_context.ts +++ b/x-pack/plugins/uptime/server/lib/requests/search/query_context.ts @@ -5,13 +5,13 @@ */ import moment from 'moment'; -import { LegacyAPICaller } from 'src/core/server'; +import { ElasticsearchClient } from 'kibana/server'; import { CursorPagination } from './types'; import { parseRelativeDate } from '../../helper'; import { CursorDirection, SortOrder } from '../../../../common/runtime_types'; export class QueryContext { - callES: LegacyAPICaller; + callES: ElasticsearchClient; heartbeatIndices: string; dateRangeStart: string; dateRangeEnd: string; @@ -43,12 +43,12 @@ export class QueryContext { async search(params: any): Promise { params.index = this.heartbeatIndices; - return this.callES('search', params); + return this.callES.search(params); } async count(params: any): Promise { params.index = this.heartbeatIndices; - return this.callES('count', params); + return this.callES.count(params); } async dateAndCustomFilters(): Promise { diff --git a/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts b/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts index a864bfa591424..6be9f813016f8 100644 --- a/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts +++ b/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts @@ -23,7 +23,7 @@ export const refinePotentialMatches = async ( return []; } - const queryResult = await query(queryContext, potentialMatchMonitorIDs); + const { body: queryResult } = await query(queryContext, potentialMatchMonitorIDs); return await fullyMatchingIds(queryResult, queryContext.statusFilter); }; diff --git a/x-pack/plugins/uptime/server/rest_api/index_state/get_index_pattern.ts b/x-pack/plugins/uptime/server/rest_api/index_state/get_index_pattern.ts index baf999158a29e..418cde9e701d5 100644 --- a/x-pack/plugins/uptime/server/rest_api/index_state/get_index_pattern.ts +++ b/x-pack/plugins/uptime/server/rest_api/index_state/get_index_pattern.ts @@ -17,8 +17,7 @@ export const createGetIndexPatternRoute: UMRestApiRouteFactory = (libs: UMServer return response.ok({ body: { ...(await libs.requests.getIndexPattern({ - callES, - esClient: _context.core.elasticsearch.client.asCurrentUser, + esClient: callES, dynamicSettings, })), }, diff --git a/x-pack/plugins/uptime/server/rest_api/monitors/monitor_list.ts b/x-pack/plugins/uptime/server/rest_api/monitors/monitor_list.ts index 0e2c8c180e0e0..7b461060bf4bc 100644 --- a/x-pack/plugins/uptime/server/rest_api/monitors/monitor_list.ts +++ b/x-pack/plugins/uptime/server/rest_api/monitors/monitor_list.ts @@ -25,44 +25,51 @@ export const createMonitorListRoute: UMRestApiRouteFactory = (libs) => ({ tags: ['access:uptime-read'], }, handler: async ({ callES, dynamicSettings }, _context, request, response): Promise => { - const { - dateRangeStart, - dateRangeEnd, - filters, - pagination, - statusFilter, - pageSize, - } = request.query; - - const decodedPagination = pagination - ? JSON.parse(decodeURIComponent(pagination)) - : CONTEXT_DEFAULTS.CURSOR_PAGINATION; - const [indexStatus, { summaries, nextPagePagination, prevPagePagination }] = await Promise.all([ - libs.requests.getIndexStatus({ callES, dynamicSettings }), - libs.requests.getMonitorStates({ - callES, - dynamicSettings, + try { + const { dateRangeStart, dateRangeEnd, - pagination: decodedPagination, - pageSize, filters, - // this is added to make typescript happy, - // this sort of reassignment used to be further downstream but I've moved it here - // because this code is going to be decomissioned soon - statusFilter: statusFilter || undefined, - }), - ]); + pagination, + statusFilter, + pageSize, + } = request.query; + + const decodedPagination = pagination + ? JSON.parse(decodeURIComponent(pagination)) + : CONTEXT_DEFAULTS.CURSOR_PAGINATION; + const [ + indexStatus, + { summaries, nextPagePagination, prevPagePagination }, + ] = await Promise.all([ + libs.requests.getIndexStatus({ callES, dynamicSettings }), + libs.requests.getMonitorStates({ + callES, + dynamicSettings, + dateRangeStart, + dateRangeEnd, + pagination: decodedPagination, + pageSize, + filters, + // this is added to make typescript happy, + // this sort of reassignment used to be further downstream but I've moved it here + // because this code is going to be decomissioned soon + statusFilter: statusFilter || undefined, + }), + ]); - const totalSummaryCount = indexStatus?.docCount ?? 0; + const totalSummaryCount = indexStatus?.docCount ?? 0; - return response.ok({ - body: { - summaries, - nextPagePagination, - prevPagePagination, - totalSummaryCount, - }, - }); + return response.ok({ + body: { + summaries, + nextPagePagination, + prevPagePagination, + totalSummaryCount, + }, + }); + } catch (e) { + return response.internalError({ body: { message: e.message } }); + } }, }); diff --git a/x-pack/plugins/uptime/server/rest_api/monitors/monitors_details.ts b/x-pack/plugins/uptime/server/rest_api/monitors/monitors_details.ts index 8bbb4fcb5575c..bb54effc0d57e 100644 --- a/x-pack/plugins/uptime/server/rest_api/monitors/monitors_details.ts +++ b/x-pack/plugins/uptime/server/rest_api/monitors/monitors_details.ts @@ -28,7 +28,6 @@ export const createGetMonitorDetailsRoute: UMRestApiRouteFactory = (libs: UMServ body: { ...(await libs.requests.getMonitorDetails({ callES, - esClient: context.core.elasticsearch.client.asCurrentUser, dynamicSettings, monitorId, dateStart, diff --git a/x-pack/plugins/uptime/server/rest_api/types.ts b/x-pack/plugins/uptime/server/rest_api/types.ts index 589cb82d550f6..5e5f4a2a991cf 100644 --- a/x-pack/plugins/uptime/server/rest_api/types.ts +++ b/x-pack/plugins/uptime/server/rest_api/types.ts @@ -9,12 +9,13 @@ import { RequestHandler, RouteConfig, RouteMethod, - LegacyCallAPIOptions, SavedObjectsClientContract, RequestHandlerContext, KibanaRequest, KibanaResponseFactory, IKibanaResponse, + IScopedClusterClient, + ElasticsearchClient, } from 'kibana/server'; import { DynamicSettings } from '../../common/runtime_types'; import { UMServerLibs } from '../lib/lib'; @@ -63,11 +64,8 @@ export type UMKibanaRouteWrapper = (uptimeRoute: UptimeRoute) => UMKibanaRoute; * This type can store custom parameters used by the internal Uptime route handlers. */ export interface UMRouteParams { - callES: ( - endpoint: string, - clientParams?: Record, - options?: LegacyCallAPIOptions | undefined - ) => Promise; + callES: ElasticsearchClient; + esClient: IScopedClusterClient; dynamicSettings: DynamicSettings; savedObjectsClient: SavedObjectsClientContract; } diff --git a/x-pack/plugins/uptime/server/rest_api/uptime_route_wrapper.ts b/x-pack/plugins/uptime/server/rest_api/uptime_route_wrapper.ts index 84a85a54afe13..b2f1c7d6424e6 100644 --- a/x-pack/plugins/uptime/server/rest_api/uptime_route_wrapper.ts +++ b/x-pack/plugins/uptime/server/rest_api/uptime_route_wrapper.ts @@ -13,11 +13,11 @@ export const uptimeRouteWrapper: UMKibanaRouteWrapper = (uptimeRoute) => ({ tags: ['access:uptime-read', ...(uptimeRoute?.writeAccess ? ['access:uptime-write'] : [])], }, handler: async (context, request, response) => { - const { callAsCurrentUser: callES } = context.core.elasticsearch.legacy.client; + const { client: esClient } = context.core.elasticsearch; const { client: savedObjectsClient } = context.core.savedObjects; const dynamicSettings = await savedObjectsAdapter.getUptimeDynamicSettings(savedObjectsClient); return uptimeRoute.handler( - { callES, savedObjectsClient, dynamicSettings }, + { callES: esClient.asCurrentUser, esClient, savedObjectsClient, dynamicSettings }, context, request, response diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/execution_status.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/execution_status.ts index 8fb89042e4a90..4058b71356280 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/execution_status.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/execution_status.ts @@ -5,6 +5,7 @@ */ import expect from '@kbn/expect'; +import { AlertExecutionStatusErrorReasons } from '../../../../../plugins/alerts/common'; import { Spaces } from '../../scenarios'; import { getUrlPrefix, getTestAlertData, ObjectRemover } from '../../../common/lib'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; @@ -49,7 +50,7 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon executionStatus = await waitForStatus(alertId, new Set(['error'])); expect(executionStatus.error).to.be.ok(); - expect(executionStatus.error.reason).to.be('decrypt'); + expect(executionStatus.error.reason).to.be(AlertExecutionStatusErrorReasons.Decrypt); expect(executionStatus.error.message).to.be('Unable to decrypt attribute "apiKey"'); }); }); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts index 92db0458c0639..c05fa6cf051ff 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts @@ -15,6 +15,7 @@ import { ObjectRemover, } from '../../../../../common/lib'; import { createEsDocuments } from './create_test_data'; +import { getAlertType } from '../../../../../../../plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/'; const ALERT_TYPE_ID = '.index-threshold'; const ACTION_TYPE_ID = '.index'; @@ -26,6 +27,8 @@ const ALERT_INTERVALS_TO_WRITE = 5; const ALERT_INTERVAL_SECONDS = 3; const ALERT_INTERVAL_MILLIS = ALERT_INTERVAL_SECONDS * 1000; +const DefaultActionMessage = getAlertType().defaultActionMessage; + // eslint-disable-next-line import/no-default-export export default function alertTests({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -62,6 +65,10 @@ export default function alertTests({ getService }: FtrProviderContext) { await esTestIndexToolOutput.destroy(); }); + it('has a default action message', () => { + expect(DefaultActionMessage).to.be.ok(); + }); + // The tests below create two alerts, one that will fire, one that will // never fire; the tests ensure the ones that should fire, do fire, and // those that shouldn't fire, do not fire. @@ -85,7 +92,7 @@ export default function alertTests({ getService }: FtrProviderContext) { const docs = await waitForDocs(2); for (const doc of docs) { const { group } = doc._source; - const { name, value, title, message } = doc._source.params; + const { name, title, message } = doc._source.params; expect(name).to.be('always fire'); expect(group).to.be('all documents'); @@ -93,9 +100,8 @@ export default function alertTests({ getService }: FtrProviderContext) { // we'll check title and message in this test, but not subsequent ones expect(title).to.be('alert always fire group all documents exceeded threshold'); - const expectedPrefix = `alert always fire group all documents value ${value} exceeded threshold count > -1 over`; - const messagePrefix = message.substr(0, expectedPrefix.length); - expect(messagePrefix).to.be(expectedPrefix); + const messagePattern = /alert always fire group all documents value \d+ exceeded threshold count > -1 over 15s on \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + expect(message).to.match(messagePattern); } }); @@ -128,10 +134,13 @@ export default function alertTests({ getService }: FtrProviderContext) { for (const doc of docs) { const { group } = doc._source; - const { name } = doc._source.params; + const { name, message } = doc._source.params; expect(name).to.be('always fire'); if (group === 'group-0') inGroup0++; + + const messagePattern = /alert always fire group group-\d value \d+ exceeded threshold count .+ over 15s on \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + expect(message).to.match(messagePattern); } // there should be 2 docs in group-0, rando split between others @@ -163,9 +172,12 @@ export default function alertTests({ getService }: FtrProviderContext) { const docs = await waitForDocs(2); for (const doc of docs) { - const { name } = doc._source.params; + const { name, message } = doc._source.params; expect(name).to.be('always fire'); + + const messagePattern = /alert always fire group all documents value \d+ exceeded threshold sum\(testedValue\) between 0,1000000 over 15s on \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + expect(message).to.match(messagePattern); } }); @@ -195,9 +207,12 @@ export default function alertTests({ getService }: FtrProviderContext) { const docs = await waitForDocs(4); for (const doc of docs) { - const { name } = doc._source.params; + const { name, message } = doc._source.params; expect(name).to.be('always fire'); + + const messagePattern = /alert always fire group all documents value .+ exceeded threshold avg\(testedValue\) .+ 0 over 15s on \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + expect(message).to.match(messagePattern); } }); @@ -232,10 +247,13 @@ export default function alertTests({ getService }: FtrProviderContext) { for (const doc of docs) { const { group } = doc._source; - const { name } = doc._source.params; + const { name, message } = doc._source.params; expect(name).to.be('always fire'); if (group === 'group-2') inGroup2++; + + const messagePattern = /alert always fire group group-. value \d+ exceeded threshold max\(testedValue\) .* 0 over 15s on \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + expect(message).to.match(messagePattern); } // there should be 2 docs in group-2, rando split between others @@ -274,10 +292,13 @@ export default function alertTests({ getService }: FtrProviderContext) { for (const doc of docs) { const { group } = doc._source; - const { name } = doc._source.params; + const { name, message } = doc._source.params; expect(name).to.be('always fire'); if (group === 'group-0') inGroup0++; + + const messagePattern = /alert always fire group group-. value \d+ exceeded threshold min\(testedValue\) .* 0 over 15s on \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + expect(message).to.match(messagePattern); } // there should be 2 docs in group-0, rando split between others @@ -329,7 +350,7 @@ export default function alertTests({ getService }: FtrProviderContext) { name: '{{{alertName}}}', value: '{{{context.value}}}', title: '{{{context.title}}}', - message: '{{{context.message}}}', + message: DefaultActionMessage, }, date: '{{{context.date}}}', // TODO: I wanted to write the alert value here, but how? diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts index 7d99d3635106d..0f6da936f8644 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts @@ -55,6 +55,12 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await nameInput.click(); } + async function defineAlwaysFiringAlert(alertName: string) { + await pageObjects.triggersActionsUI.clickCreateAlertButton(); + await testSubjects.setValue('alertNameInput', alertName); + await testSubjects.click('test.always-firing-SelectOption'); + } + describe('create alert', function () { before(async () => { await pageObjects.common.navigateToApp('triggersActions'); @@ -73,10 +79,13 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await find.clickByCssSelector('[data-test-subj="saveActionButtonModal"]:not(disabled)'); const createdConnectorToastTitle = await pageObjects.common.closeToast(); expect(createdConnectorToastTitle).to.eql(`Created '${slackConnectorName}'`); + const messageTextArea = await find.byCssSelector('[data-test-subj="messageTextArea"]'); + expect(await messageTextArea.getAttribute('value')).to.eql( + 'alert {{alertName}} group {{context.group}} value {{context.value}} exceeded threshold {{context.function}} over {{params.timeWindowSize}}{{params.timeWindowUnit}} on {{context.date}}' + ); await testSubjects.setValue('messageTextArea', 'test message '); await testSubjects.click('messageAddVariableButton'); await testSubjects.click('variableMenuButton-0'); - const messageTextArea = await find.byCssSelector('[data-test-subj="messageTextArea"]'); expect(await messageTextArea.getAttribute('value')).to.eql('test message {{alertId}}'); await messageTextArea.type(' some additional text '); @@ -106,6 +115,57 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await deleteAlerts(alertsToDelete.map((alertItem: { id: string }) => alertItem.id)); }); + it('should create an alert with actions in multiple groups', async () => { + const alertName = generateUniqueKey(); + await defineAlwaysFiringAlert(alertName); + + // create Slack connector and attach an action using it + await testSubjects.click('.slack-ActionTypeSelectOption'); + await testSubjects.click('addNewActionConnectorButton-.slack'); + const slackConnectorName = generateUniqueKey(); + await testSubjects.setValue('nameInput', slackConnectorName); + await testSubjects.setValue('slackWebhookUrlInput', 'https://test'); + await find.clickByCssSelector('[data-test-subj="saveActionButtonModal"]:not(disabled)'); + const createdConnectorToastTitle = await pageObjects.common.closeToast(); + expect(createdConnectorToastTitle).to.eql(`Created '${slackConnectorName}'`); + await testSubjects.setValue('messageTextArea', 'test message '); + await ( + await find.byCssSelector( + '[data-test-subj="alertActionAccordion-0"] [data-test-subj="messageTextArea"]' + ) + ).type('some text '); + + await testSubjects.click('addAlertActionButton'); + await testSubjects.click('.slack-ActionTypeSelectOption'); + await testSubjects.setValue('messageTextArea', 'test message '); + await ( + await find.byCssSelector( + '[data-test-subj="alertActionAccordion-1"] [data-test-subj="messageTextArea"]' + ) + ).type('some text '); + + await testSubjects.click('addNewActionConnectorActionGroup-1'); + await testSubjects.click('addNewActionConnectorActionGroup-1-option-other'); + + await testSubjects.click('saveAlertButton'); + const toastTitle = await pageObjects.common.closeToast(); + expect(toastTitle).to.eql(`Created alert "${alertName}"`); + await pageObjects.triggersActionsUI.searchAlerts(alertName); + const searchResultsAfterSave = await pageObjects.triggersActionsUI.getAlertsList(); + expect(searchResultsAfterSave).to.eql([ + { + name: alertName, + tagsText: '', + alertType: 'Always Firing', + interval: '1m', + }, + ]); + + // clean up created alert + const alertsToDelete = await getAlertsByName(alertName); + await deleteAlerts(alertsToDelete.map((alertItem: { id: string }) => alertItem.id)); + }); + it('should show save confirmation before creating alert with no actions', async () => { const alertName = generateUniqueKey(); await defineAlert(alertName); 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 9e4006681dc8d..1d86d95b7a796 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 @@ -306,7 +306,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); }); - describe('Alert Instances', function () { + // FLAKY: https://github.com/elastic/kibana/issues/57426 + describe.skip('Alert Instances', function () { const testRunUuid = uuid.v4(); let alert: any; diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts index e3927f6bfffb9..6f9d010378624 100644 --- a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts @@ -78,6 +78,7 @@ function createAlwaysFiringAlertType(alerts: AlertingSetup) { { id: 'default', name: 'Default' }, { id: 'other', name: 'Other' }, ], + defaultActionGroupId: 'default', producer: 'alerts', async executor(alertExecutorOptions: any) { const { services, state, params } = alertExecutorOptions; diff --git a/yarn.lock b/yarn.lock index 833e8bffcfc80..0b429c96c1847 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10370,17 +10370,6 @@ cross-fetch@2.2.2: node-fetch "2.1.2" whatwg-fetch "2.0.4" -cross-spawn@6.0.5, cross-spawn@^6.0.0, cross-spawn@^6.0.5: - version "6.0.5" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" - integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== - dependencies: - nice-try "^1.0.4" - path-key "^2.0.1" - semver "^5.5.0" - shebang-command "^1.2.0" - which "^1.2.9" - cross-spawn@7.0.1, cross-spawn@^7.0.0: version "7.0.1" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.1.tgz#0ab56286e0f7c24e153d04cc2aa027e43a9a5d14" @@ -10398,6 +10387,17 @@ cross-spawn@^3.0.0: lru-cache "^4.0.1" which "^1.2.9" +cross-spawn@^6.0.0, cross-spawn@^6.0.5: + version "6.0.5" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" + integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== + dependencies: + nice-try "^1.0.4" + path-key "^2.0.1" + semver "^5.5.0" + shebang-command "^1.2.0" + which "^1.2.9" + crypt@~0.0.1: version "0.0.2" resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" @@ -12245,16 +12245,7 @@ endent@^2.0.1: fast-json-parse "^1.0.3" objectorarray "^1.0.4" -enhanced-resolve@4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.1.0.tgz#41c7e0bfdfe74ac1ffe1e57ad6a5c6c9f3742a7f" - integrity sha512-F/7vkyTtyc/llOIn8oWclcB25KdRaiPBpZYDgJHgh/UHtpgT2p2eldQgtQnLtUvfMKPKxbRaQM/hHkvLHt1Vng== - dependencies: - graceful-fs "^4.1.2" - memory-fs "^0.4.0" - tapable "^1.0.0" - -enhanced-resolve@^4.0.0, enhanced-resolve@^4.1.0, enhanced-resolve@^4.3.0: +enhanced-resolve@^4.0.0, enhanced-resolve@^4.1.0, enhanced-resolve@^4.1.1, enhanced-resolve@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.3.0.tgz#3b806f3bfafc1ec7de69551ef93cca46c1704126" integrity sha512-3e87LvavsdxyoCfGusJnrZ5G8SLPOFeHSNpZI/ATL9a5leXo2k0w6MKnbqhdBad9qTobSfB20Ld7UmgoNbAZkQ== @@ -13749,16 +13740,6 @@ find@^0.3.0: dependencies: traverse-chain "~0.1.0" -findup-sync@3.0.0, findup-sync@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-3.0.0.tgz#17b108f9ee512dfb7a5c7f3c8b27ea9e1a9c08d1" - integrity sha512-YbffarhcicEhOrm4CtrwdKBdCuz576RLdhJDsIfvNtxUuhdRet1qZcsMjqbePtAseKdAnDyM/IyXbu7PRPRLYg== - dependencies: - detect-file "^1.0.0" - is-glob "^4.0.0" - micromatch "^3.0.4" - resolve-dir "^1.0.1" - findup-sync@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-2.0.0.tgz#9326b1488c22d1a6088650a86901b2d9a90a2cbc" @@ -13769,6 +13750,16 @@ findup-sync@^2.0.0: micromatch "^3.0.4" resolve-dir "^1.0.1" +findup-sync@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-3.0.0.tgz#17b108f9ee512dfb7a5c7f3c8b27ea9e1a9c08d1" + integrity sha512-YbffarhcicEhOrm4CtrwdKBdCuz576RLdhJDsIfvNtxUuhdRet1qZcsMjqbePtAseKdAnDyM/IyXbu7PRPRLYg== + dependencies: + detect-file "^1.0.0" + is-glob "^4.0.0" + micromatch "^3.0.4" + resolve-dir "^1.0.1" + findup-sync@~0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-0.3.0.tgz#37930aa5d816b777c03445e1966cc6790a4c0b16" @@ -14637,7 +14628,7 @@ global-dirs@^2.0.1: dependencies: ini "^1.3.5" -global-modules@2.0.0: +global-modules@2.0.0, global-modules@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780" integrity sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A== @@ -16195,7 +16186,7 @@ import-lazy@^2.1.0: resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43" integrity sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM= -import-local@2.0.0, import-local@^2.0.0: +import-local@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/import-local/-/import-local-2.0.0.tgz#55070be38a5993cf18ef6db7e961f5bee5c5a09d" integrity sha512-b6s04m3O+s3CGSbqDIyP4R6aAwAeYlVq9+WUWep6iHa8ETRf9yei1U48C5MmfJmV9AiLYYBKPMq/W+/WRpQmCQ== @@ -16430,10 +16421,10 @@ internal-slot@^1.0.2: has "^1.0.3" side-channel "^1.0.2" -interpret@1.2.0, interpret@^1.0.0, interpret@^1.1.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296" - integrity sha512-mT34yGKMNceBQUoVn7iCDKDntA7SC6gycMAWzGx1z/CMCTV7b2AAtXlo3nRyHZ1FelRkQbQjprHSYGwzLtkVbw== +interpret@^1.0.0, interpret@^1.1.0, interpret@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e" + integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA== interpret@^2.0.0: version "2.2.0" @@ -16481,11 +16472,6 @@ invert-kv@^1.0.0: resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" integrity sha1-EEqOSqym09jNFXqO+L+rLXo//bY= -invert-kv@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-2.0.0.tgz#7393f5afa59ec9ff5f67a27620d11c226e3eec02" - integrity sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA== - io-ts@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/io-ts/-/io-ts-2.0.5.tgz#e6e3db9df8b047f9cbd6b69e7d2ad3e6437a0b13" @@ -18624,13 +18610,6 @@ lcid@^1.0.0: dependencies: invert-kv "^1.0.0" -lcid@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/lcid/-/lcid-2.0.0.tgz#6ef5d2df60e52f82eb228a4c373e8d1f397253cf" - integrity sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA== - dependencies: - invert-kv "^2.0.0" - lead@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/lead/-/lead-1.0.0.tgz#6f14f99a37be3a9dd784f5495690e5903466ee42" @@ -18916,6 +18895,15 @@ loader-utils@2.0.0, loader-utils@^2.0.0: emojis-list "^3.0.0" json5 "^2.1.2" +loader-utils@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.0.tgz#c579b5e34cb34b1a74edc6c1fb36bfa371d5a613" + integrity sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA== + dependencies: + big.js "^5.2.2" + emojis-list "^3.0.0" + json5 "^1.0.1" + locate-path@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" @@ -19449,13 +19437,6 @@ makeerror@1.0.x: dependencies: tmpl "1.0.x" -map-age-cleaner@^0.1.1: - version "0.1.2" - resolved "https://registry.yarnpkg.com/map-age-cleaner/-/map-age-cleaner-0.1.2.tgz#098fb15538fd3dbe461f12745b0ca8568d4e3f74" - integrity sha512-UN1dNocxQq44IhJyMI4TU8phc2m9BddacHRPRjKGLYaF0jqd3xLz0jS0skpAU9WgYyoR4gHtUpzytNBS385FWQ== - dependencies: - p-defer "^1.0.0" - map-cache@^0.2.0, map-cache@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" @@ -19689,15 +19670,6 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= -mem@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/mem/-/mem-4.0.0.tgz#6437690d9471678f6cc83659c00cbafcd6b0cdaf" - integrity sha512-WQxG/5xYc3tMbYLXoXPm81ET2WDULiU5FxbuIoNbJqLOOI8zehXFdZuiUEgfdrU2mVB1pxBZUGlYORSrpuJreA== - dependencies: - map-age-cleaner "^0.1.1" - mimic-fn "^1.0.0" - p-is-promise "^1.1.0" - "memoize-one@>=3.1.1 <6", memoize-one@^5.0.0, memoize-one@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0" @@ -19729,7 +19701,7 @@ memory-fs@^0.2.0: resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.2.0.tgz#f2bb25368bc121e391c2520de92969caee0a0290" integrity sha1-8rslNovBIeORwlIN6Slpyu4KApA= -memory-fs@^0.4.0, memory-fs@^0.4.1: +memory-fs@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552" integrity sha1-OpoguEYlI+RHz7x+i7gO1me/xVI= @@ -20620,11 +20592,6 @@ node-fetch@2.1.2, node-fetch@^1.0.1, node-fetch@^2.3.0, node-fetch@^2.6.0, node- resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== -node-forge@0.9.0: - version "0.9.0" - resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.0.tgz#d624050edbb44874adca12bb9a52ec63cb782579" - integrity sha512-7ASaDa3pD+lJ3WvXFsxekJQelBKRpne+GOVbLbtHYdd7pFspyeuJHnWfLplGf3SwKGbfs/aYl5V/JCIaHVUKKQ== - node-forge@^0.10.0, node-forge@^0.7.6: version "0.10.0" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3" @@ -21408,15 +21375,6 @@ os-locale@^1.4.0: dependencies: lcid "^1.0.0" -os-locale@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-3.1.0.tgz#a802a6ee17f24c10483ab9935719cef4ed16bf1a" - integrity sha512-Z8l3R4wYWM40/52Z+S265okfFj8Kt2cC2MKY+xNi3kFs+XGI7WXu/I309QQQYbRW4ijiZ+yxs9pqEhJh0DqW3Q== - dependencies: - execa "^1.0.0" - lcid "^2.0.0" - mem "^4.0.0" - os-name@^3.0.0, os-name@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/os-name/-/os-name-3.1.0.tgz#dec19d966296e1cd62d701a5a66ee1ddeae70801" @@ -21477,11 +21435,6 @@ p-cancelable@^2.0.0: resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.0.0.tgz#4a3740f5bdaf5ed5d7c3e34882c6fb5d6b266a6e" integrity sha512-wvPXDmbMmu2ksjkB4Z3nZWTSkJEb9lqVdMaCKpZUGJG9TMiNp9XcbG3fn9fPKjem04fJMJnXoyFPk2FmgiaiNg== -p-defer@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-1.0.0.tgz#9f6eb182f6c9aa8cd743004a7d4f96b196b0fb0c" - integrity sha1-n26xgvbJqozXQwBKfU+WsZaw+ww= - p-each-series@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/p-each-series/-/p-each-series-2.1.0.tgz#961c8dd3f195ea96c747e636b262b800a6b1af48" @@ -21506,11 +21459,6 @@ p-finally@^1.0.0: resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= -p-is-promise@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-1.1.0.tgz#9c9456989e9f6588017b0434d56097675c3da05e" - integrity sha1-nJRWmJ6fZYgBewQ01WCXZ1w9oF4= - p-limit@^1.1.0: version "1.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" @@ -25133,11 +25081,11 @@ selenium-webdriver@^4.0.0-alpha.7: tmp "0.0.30" selfsigned@^1.10.7: - version "1.10.7" - resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-1.10.7.tgz#da5819fd049d5574f28e88a9bcc6dbc6e6f3906b" - integrity sha512-8M3wBCzeWIJnQfl43IKwOmC4H/RAp50S8DF60znzjW5GVqTcSe2vWclt7hmYVPkKPlHWOu5EaWOMZ2Y6W8ZXTA== + version "1.10.8" + resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-1.10.8.tgz#0d17208b7d12c33f8eac85c41835f27fc3d81a30" + integrity sha512-2P4PtieJeEwVgTU9QEcwIRDQ/mXJLX8/+I3ur+Pg16nS8oNbrGxEso9NyYWy8NAmXiNl4dlAp5MwoNeCWzON4w== dependencies: - node-forge "0.9.0" + node-forge "^0.10.0" semver-diff@^2.0.0: version "2.1.0" @@ -26572,13 +26520,6 @@ supports-color@6.0.0: dependencies: has-flag "^3.0.0" -supports-color@6.1.0, supports-color@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3" - integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ== - dependencies: - has-flag "^3.0.0" - supports-color@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-0.2.0.tgz#d92de2694eb3f67323973d7ae3d8b55b4c22190a" @@ -26596,6 +26537,13 @@ supports-color@^5.0.0, supports-color@^5.3.0, supports-color@^5.4.0, supports-co dependencies: has-flag "^3.0.0" +supports-color@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3" + integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ== + dependencies: + has-flag "^3.0.0" + supports-color@^7.0.0, supports-color@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1" @@ -28406,10 +28354,10 @@ uuid@^8.0.0, uuid@^8.3.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.0.tgz#ab738085ca22dc9a8c92725e459b1d507df5d6ea" integrity sha512-fX6Z5o4m6XsXBdli9g7DtWgAx+osMsRRZFKma1mIUsLCz6vRvv+pz5VNbyu9UEDzpMWulZfvpgb/cmDXVulYFQ== -v8-compile-cache@2.0.3, v8-compile-cache@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.0.3.tgz#00f7494d2ae2b688cfe2899df6ed2c54bef91dbe" - integrity sha512-CNmdbwQMBjwr9Gsmohvm0pbL954tJrNzf6gWL3K+QMQf00PF7ERGrEiLgjuU3mKreLC2MeGhUsNV9ybTbLgd3w== +v8-compile-cache@^2.0.3, v8-compile-cache@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.2.0.tgz#9471efa3ef9128d2f7c6a7ca39c4dd6b5055b132" + integrity sha512-gTpR5XQNKFwOd4clxfnhaqvfqMpqEwr4tOtCyz4MtYZX2JYhfr1JvBFKdS+7K/9rfpZR3VLX+YWBbKoxCgS43Q== v8-to-istanbul@^5.0.1: version "5.0.1" @@ -29156,22 +29104,22 @@ webidl-conversions@^6.1.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514" integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w== -webpack-cli@^3.3.10: - version "3.3.10" - resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-3.3.10.tgz#17b279267e9b4fb549023fae170da8e6e766da13" - integrity sha512-u1dgND9+MXaEt74sJR4PR7qkPxXUSQ0RXYq8x1L6Jg1MYVEmGPrH6Ah6C4arD4r0J1P5HKjRqpab36k0eIzPqg== +webpack-cli@^3.3.12: + version "3.3.12" + resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-3.3.12.tgz#94e9ada081453cd0aa609c99e500012fd3ad2d4a" + integrity sha512-NVWBaz9k839ZH/sinurM+HcDvJOTXwSjYp1ku+5XKeOC03z8v5QitnK/x+lAxGXFyhdayoIf/GOpv85z3/xPag== dependencies: - chalk "2.4.2" - cross-spawn "6.0.5" - enhanced-resolve "4.1.0" - findup-sync "3.0.0" - global-modules "2.0.0" - import-local "2.0.0" - interpret "1.2.0" - loader-utils "1.2.3" - supports-color "6.1.0" - v8-compile-cache "2.0.3" - yargs "13.2.4" + chalk "^2.4.2" + cross-spawn "^6.0.5" + enhanced-resolve "^4.1.1" + findup-sync "^3.0.0" + global-modules "^2.0.0" + import-local "^2.0.0" + interpret "^1.4.0" + loader-utils "^1.4.0" + supports-color "^6.1.0" + v8-compile-cache "^2.1.1" + yargs "^13.3.2" webpack-dev-middleware@^3.7.0, webpack-dev-middleware@^3.7.2: version "3.7.2" @@ -29184,7 +29132,7 @@ webpack-dev-middleware@^3.7.0, webpack-dev-middleware@^3.7.2: range-parser "^1.2.1" webpack-log "^2.0.0" -webpack-dev-server@^3.8.2: +webpack-dev-server@^3.11.0: version "3.11.0" resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-3.11.0.tgz#8f154a3bce1bcfd1cc618ef4e703278855e7ff8c" integrity sha512-PUxZ+oSTxogFQgkTtFndEtJIPNmml7ExwufBZ9L2/Xyyd5PnOL5UreWe5ZT7IU25DSdykL9p1MLQzmLh2ljSeg== @@ -29806,7 +29754,7 @@ yaml@^1.7.2: resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.0.tgz#3b593add944876077d4d683fee01081bd9fff31e" integrity sha512-yr2icI4glYaNG+KWONODapy2/jDdMSDnrONSjblABjD9B4Z5LgiircSt8m8sRZFNi08kG9Sm0uSHtEmP3zaEGg== -yargs-parser@13.1.2, yargs-parser@^13.1.0, yargs-parser@^13.1.2: +yargs-parser@13.1.2, yargs-parser@^13.1.2: version "13.1.2" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg== @@ -29844,23 +29792,6 @@ yargs-unparser@1.6.0: lodash "^4.17.15" yargs "^13.3.0" -yargs@13.2.4: - version "13.2.4" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.2.4.tgz#0b562b794016eb9651b98bd37acf364aa5d6dc83" - integrity sha512-HG/DWAJa1PAnHT9JAhNa8AbAv3FPaiLzioSjCcmuXXhP8MlpHO5vwls4g4j6n30Z74GVQj8Xa62dWVx1QCGklg== - dependencies: - cliui "^5.0.0" - find-up "^3.0.0" - get-caller-file "^2.0.1" - os-locale "^3.1.0" - require-directory "^2.1.1" - require-main-filename "^2.0.0" - set-blocking "^2.0.0" - string-width "^3.0.0" - which-module "^2.0.0" - y18n "^4.0.0" - yargs-parser "^13.1.0" - yargs@13.3.2, yargs@^13.2.2, yargs@^13.3.0, yargs@^13.3.2: version "13.3.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd"