From 4d593bbc086a255db20f64a666d2f5566f982108 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Mon, 12 Apr 2021 17:27:23 -0500 Subject: [PATCH 01/90] [Workplace Search] Design polish: Groups, Security and Custom source (#96870) * Add missing i18n Oops * Change button color * Fix custom source created screen * Add better empty state to groups * Align toggle to right side of table * Update design for security page --- .../components/add_source/save_custom.tsx | 3 +- .../groups/components/group_overview.test.tsx | 15 +++------ .../groups/components/group_overview.tsx | 31 ++++++++++++++++--- .../workplace_search/views/groups/groups.tsx | 2 +- .../components/private_sources_table.tsx | 2 +- .../views/security/security.tsx | 9 ++++-- 6 files changed, 41 insertions(+), 21 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx index 1bf8239a6b3994..9689ecfae4a94d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx @@ -62,9 +62,10 @@ export const SaveCustom: React.FC = ({ }) => ( <> {header} + - + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.test.tsx index e39d72a861b6fe..8d5714fd057929 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.test.tsx @@ -12,18 +12,14 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiFieldText } from '@elastic/eui'; +import { EuiFieldText, EuiEmptyPrompt } from '@elastic/eui'; import { Loading } from '../../../../shared/loading'; import { ContentSection } from '../../../components/shared/content_section'; import { SourcesTable } from '../../../components/shared/sources_table'; import { ViewContentHeader } from '../../../components/shared/view_content_header'; -import { - GroupOverview, - EMPTY_SOURCES_DESCRIPTION, - EMPTY_USERS_DESCRIPTION, -} from './group_overview'; +import { GroupOverview } from './group_overview'; const deleteGroup = jest.fn(); const showSharedSourcesModal = jest.fn(); @@ -92,7 +88,7 @@ describe('GroupOverview', () => { expect(updateGroupName).toHaveBeenCalled(); }); - it('renders empty state messages', () => { + it('renders empty state', () => { setMockValues({ ...mockValues, group: { @@ -103,10 +99,7 @@ describe('GroupOverview', () => { }); const wrapper = shallow(); - const sourcesSection = wrapper.find('[data-test-subj="GroupContentSourcesSection"]') as any; - const usersSection = wrapper.find('[data-test-subj="GroupUsersSection"]') as any; - expect(sourcesSection.prop('description')).toEqual(EMPTY_SOURCES_DESCRIPTION); - expect(usersSection.prop('description')).toEqual(EMPTY_USERS_DESCRIPTION); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx index 375ac7476f9b69..364ca0ba472567 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx @@ -12,10 +12,12 @@ import { useActions, useValues } from 'kea'; import { EuiButton, EuiConfirmModal, + EuiEmptyPrompt, EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiFormRow, + EuiPanel, EuiSpacer, EuiHorizontalRule, } from '@elastic/eui'; @@ -24,6 +26,7 @@ import { i18n } from '@kbn/i18n'; import { Loading } from '../../../../shared/loading'; import { TruncatedContent } from '../../../../shared/truncate'; import { AppLogic } from '../../../app_logic'; +import noSharedSourcesIcon from '../../../assets/share_circle.svg'; import { ContentSection } from '../../../components/shared/content_section'; import { SourcesTable } from '../../../components/shared/sources_table'; import { ViewContentHeader } from '../../../components/shared/view_content_header'; @@ -145,6 +148,12 @@ export const GroupOverview: React.FC = () => { values: { name }, } ); + const GROUP_SOURCES_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.groupSourcesTitle', + { + defaultMessage: 'Group content sources', + } + ); const GROUP_SOURCES_DESCRIPTION = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.groups.overview.groupSourcesDescription', { @@ -170,15 +179,29 @@ export const GroupOverview: React.FC = () => { const sourcesSection = ( - {hasContentSources && sourcesTable} + {sourcesTable} ); + const sourcesEmptyState = ( + <> + + {GROUP_SOURCES_TITLE}} + body={

{EMPTY_SOURCES_DESCRIPTION}

} + actions={manageSourcesButton} + /> +
+ + + ); + const usersSection = !isFederatedAuth && ( { <> - {sourcesSection} + {hasContentSources ? sourcesSection : sourcesEmptyState} {usersSection} {nameSection} {canDeleteGroup && deleteSection} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.tsx index b2bf0364b2d1f3..b82e141bc810ef 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.tsx @@ -60,7 +60,7 @@ export const Groups: React.FC = () => { messages[0].description = ( {i18n.translate('xpack.enterpriseSearch.workplaceSearch.groups.newGroup.action', { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.tsx index 312745ee7496c7..68f2a2289c1f26 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.tsx @@ -152,7 +152,7 @@ export const PrivateSourcesTable: React.FC = ({ {contentSources.map((source, i) => ( {source.name} - + { { messageText={SECURITY_UNSAVED_CHANGES_MESSAGE} /> {header} - {allSourcesToggle} - {!hasPlatinumLicense && platinumLicenseCallout} - {sourceTables} + + {allSourcesToggle} + {!hasPlatinumLicense && platinumLicenseCallout} + {sourceTables} + {confirmModalVisible && confirmModal} ); From 39f87f45600f0c1b6257a5b63ab9012c9f8e846c Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Mon, 12 Apr 2021 17:52:42 -0500 Subject: [PATCH 02/90] [Security Solution][Timeline] Rebuild nested fields structure from fields response (#96187) * First pass at rebuilding nested object structure from fields response * Always requests TIMELINE_CTI_FIELDS as part of request This only works for one level of nesting; will be extending tests to allow for multiple levels momentarily. * Build objects from arbitrary levels of nesting This is a recursive implementation, but recursion depth is limited to the number of levels of nesting, with arguments reducing in size as we go (i.e. logarithmic) * Simplify parsing logic, perf improvements * Order short-circuiting conditions by cost, ascending * Simplify object building for non-nested objects from fields * The non-nested case is the same as the base recursive case, so always call our recursive function if building from .fields * Simplify getNestedParentPath * We can do a few simple string comparison rather than building up multiple strings/arrays * Don't call getNestedParentPath unnecessarily, only if we have a field * Simplify if branching By definition, nestedParentFieldName can never be equal to fieldName, which means there are only two branches here. * Declare/export a more accurate fields type Each top-level field value can be either an array of leaf values (unknown[]), or an array of nested fields. * Remove unnecessary condition If fieldName is null or undefined, there is no reason to search for it in dataFields. Looking through the git history this looks to be dead code as a result of refactoring, as opposed to a legitimate bugfix, so I'm removing it. * Fix failing tests * one was a test failure due to my modifying mock data * one may have been a legitimate bug where we don't handle a hit without a fields response; I need to follow up with Xavier to verify. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../matrix_histogram/events/index.ts | 4 +- .../common/utils/mock_event_details.ts | 6 +- .../timeline/factory/events/all/constants.ts | 10 + .../factory/events/all/helpers.test.ts | 209 +++++++++++++++++- .../timeline/factory/events/all/helpers.ts | 65 ++++-- 5 files changed, 260 insertions(+), 34 deletions(-) diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/matrix_histogram/events/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/matrix_histogram/events/index.ts index 53cdc7239f69d2..b2e0461b0b9b8a 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/matrix_histogram/events/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/matrix_histogram/events/index.ts @@ -26,10 +26,12 @@ export interface EventsActionGroupData { doc_count: number; } +export type Fields = Record; + export interface EventHit extends SearchHit { sort: string[]; _source: EventSource; - fields: Record; + fields: Fields; aggregations: { // eslint-disable-next-line @typescript-eslint/no-explicit-any [agg: string]: any; diff --git a/x-pack/plugins/security_solution/common/utils/mock_event_details.ts b/x-pack/plugins/security_solution/common/utils/mock_event_details.ts index 13b7fe70512460..7dc257ebb3feff 100644 --- a/x-pack/plugins/security_solution/common/utils/mock_event_details.ts +++ b/x-pack/plugins/security_solution/common/utils/mock_event_details.ts @@ -40,7 +40,7 @@ export const eventHit = { 'source.geo.location': [{ coordinates: [118.7778, 32.0617], type: 'Point' }], 'threat.indicator': [ { - 'matched.field': ['matched_field'], + 'matched.field': ['matched_field', 'other_matched_field'], first_seen: ['2021-02-22T17:29:25.195Z'], provider: ['yourself'], type: ['custom'], @@ -259,8 +259,8 @@ export const eventDetailsFormattedFields = [ { category: 'threat', field: 'threat.indicator.matched.field', - values: ['matched_field', 'matched_field_2'], - originalValue: ['matched_field', 'matched_field_2'], + values: ['matched_field', 'other_matched_field', 'matched_field_2'], + originalValue: ['matched_field', 'other_matched_field', 'matched_field_2'], isObjectArray: false, }, { diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/constants.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/constants.ts index 15d0e2d5494b8c..29b0df9e4bbf73 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/constants.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/constants.ts @@ -5,6 +5,15 @@ * 2.0. */ +export const TIMELINE_CTI_FIELDS = [ + 'threat.indicator.event.dataset', + 'threat.indicator.event.reference', + 'threat.indicator.matched.atomic', + 'threat.indicator.matched.field', + 'threat.indicator.matched.type', + 'threat.indicator.provider', +]; + export const TIMELINE_EVENTS_FIELDS = [ '@timestamp', 'signal.status', @@ -230,4 +239,5 @@ export const TIMELINE_EVENTS_FIELDS = [ 'zeek.ssl.established', 'zeek.ssl.resumed', 'zeek.ssl.version', + ...TIMELINE_CTI_FIELDS, ]; diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.test.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.test.ts index 405ddba137dae8..da19df32ac87ac 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.test.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.test.ts @@ -5,10 +5,10 @@ * 2.0. */ +import { eventHit } from '../../../../../../common/utils/mock_event_details'; import { EventHit } from '../../../../../../common/search_strategy'; import { TIMELINE_EVENTS_FIELDS } from './constants'; -import { formatTimelineData } from './helpers'; -import { eventHit } from '../../../../../../common/utils/mock_event_details'; +import { buildObjectForFieldPath, formatTimelineData } from './helpers'; describe('#formatTimelineData', () => { it('happy path', async () => { @@ -42,12 +42,12 @@ describe('#formatTimelineData', () => { value: ['beats-ci-immutable-ubuntu-1804-1605624279743236239'], }, { - field: 'source.geo.location', - value: [`{"lon":118.7778,"lat":32.0617}`], + field: 'threat.indicator.matched.field', + value: ['matched_field', 'other_matched_field', 'matched_field_2'], }, { - field: 'threat.indicator.matched.field', - value: ['matched_field', 'matched_field_2'], + field: 'source.geo.location', + value: [`{"lon":118.7778,"lat":32.0617}`], }, ], ecs: { @@ -94,6 +94,34 @@ describe('#formatTimelineData', () => { user: { name: ['jenkins'], }, + threat: { + indicator: [ + { + event: { + dataset: [], + reference: [], + }, + matched: { + atomic: ['matched_atomic'], + field: ['matched_field', 'other_matched_field'], + type: [], + }, + provider: ['yourself'], + }, + { + event: { + dataset: [], + reference: [], + }, + matched: { + atomic: ['matched_atomic_2'], + field: ['matched_field_2'], + type: [], + }, + provider: ['other_you'], + }, + ], + }, }, }, }); @@ -371,4 +399,173 @@ describe('#formatTimelineData', () => { }, }); }); + + describe('buildObjectForFieldPath', () => { + it('builds an object from a single non-nested field', () => { + expect(buildObjectForFieldPath('@timestamp', eventHit)).toEqual({ + '@timestamp': ['2020-11-17T14:48:08.922Z'], + }); + }); + + it('builds an object with no fields response', () => { + const { fields, ...fieldLessHit } = eventHit; + // @ts-expect-error fieldLessHit is intentionally missing fields + expect(buildObjectForFieldPath('@timestamp', fieldLessHit)).toEqual({ + '@timestamp': [], + }); + }); + + it('does not misinterpret non-nested fields with a common prefix', () => { + // @ts-expect-error hit is minimal + const hit: EventHit = { + fields: { + 'foo.bar': ['baz'], + 'foo.barBaz': ['foo'], + }, + }; + + expect(buildObjectForFieldPath('foo.barBaz', hit)).toEqual({ + foo: { barBaz: ['foo'] }, + }); + }); + + it('builds an array of objects from a nested field', () => { + // @ts-expect-error hit is minimal + const hit: EventHit = { + fields: { + foo: [{ bar: ['baz'] }], + }, + }; + expect(buildObjectForFieldPath('foo.bar', hit)).toEqual({ + foo: [{ bar: ['baz'] }], + }); + }); + + it('builds intermediate objects for nested fields', () => { + // @ts-expect-error nestedHit is minimal + const nestedHit: EventHit = { + fields: { + 'foo.bar': [ + { + baz: ['host.name'], + }, + ], + }, + }; + expect(buildObjectForFieldPath('foo.bar.baz', nestedHit)).toEqual({ + foo: { + bar: [ + { + baz: ['host.name'], + }, + ], + }, + }); + }); + + it('builds intermediate objects at multiple levels', () => { + expect(buildObjectForFieldPath('threat.indicator.matched.atomic', eventHit)).toEqual({ + threat: { + indicator: [ + { + matched: { + atomic: ['matched_atomic'], + }, + }, + { + matched: { + atomic: ['matched_atomic_2'], + }, + }, + ], + }, + }); + }); + + it('preserves multiple values for a single leaf', () => { + expect(buildObjectForFieldPath('threat.indicator.matched.field', eventHit)).toEqual({ + threat: { + indicator: [ + { + matched: { + field: ['matched_field', 'other_matched_field'], + }, + }, + { + matched: { + field: ['matched_field_2'], + }, + }, + ], + }, + }); + }); + + describe('multiple levels of nested fields', () => { + let nestedHit: EventHit; + + beforeEach(() => { + // @ts-expect-error nestedHit is minimal + nestedHit = { + fields: { + 'nested_1.foo': [ + { + 'nested_2.bar': [ + { leaf: ['leaf_value'], leaf_2: ['leaf_2_value'] }, + { leaf_2: ['leaf_2_value_2', 'leaf_2_value_3'] }, + ], + }, + { + 'nested_2.bar': [ + { leaf: ['leaf_value_2'], leaf_2: ['leaf_2_value_4'] }, + { leaf: ['leaf_value_3'], leaf_2: ['leaf_2_value_5'] }, + ], + }, + ], + }, + }; + }); + + it('includes objects without the field', () => { + expect(buildObjectForFieldPath('nested_1.foo.nested_2.bar.leaf', nestedHit)).toEqual({ + nested_1: { + foo: [ + { + nested_2: { + bar: [{ leaf: ['leaf_value'] }, { leaf: [] }], + }, + }, + { + nested_2: { + bar: [{ leaf: ['leaf_value_2'] }, { leaf: ['leaf_value_3'] }], + }, + }, + ], + }, + }); + }); + + it('groups multiple leaf values', () => { + expect(buildObjectForFieldPath('nested_1.foo.nested_2.bar.leaf_2', nestedHit)).toEqual({ + nested_1: { + foo: [ + { + nested_2: { + bar: [ + { leaf_2: ['leaf_2_value'] }, + { leaf_2: ['leaf_2_value_2', 'leaf_2_value_3'] }, + ], + }, + }, + { + nested_2: { + bar: [{ leaf_2: ['leaf_2_value_4'] }, { leaf_2: ['leaf_2_value_5'] }], + }, + }, + ], + }, + }); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts index 2c18fb28408654..6c20843058ff16 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts @@ -5,9 +5,12 @@ * 2.0. */ +import { set } from '@elastic/safer-lodash-set'; import { get, has, merge, uniq } from 'lodash/fp'; +import { Ecs } from '../../../../../../common/ecs'; import { EventHit, + Fields, TimelineEdges, TimelineNonEcsData, } from '../../../../../../common/search_strategy'; @@ -78,18 +81,13 @@ const getValuesFromFields = async ( [fieldName]: get(fieldName, hit._source), }; } else { - if (nestedParentFieldName == null || nestedParentFieldName === fieldName) { + if (nestedParentFieldName == null) { fieldToEval = { [fieldName]: hit.fields[fieldName], }; - } else if (nestedParentFieldName != null) { - fieldToEval = { - [nestedParentFieldName]: hit.fields[nestedParentFieldName], - }; } else { - // fallback, should never hit fieldToEval = { - [fieldName]: [], + [nestedParentFieldName]: hit.fields[nestedParentFieldName], }; } } @@ -102,6 +100,37 @@ const getValuesFromFields = async ( ); }; +const buildObjectRecursive = (fieldPath: string, fields: Fields): Partial => { + const nestedParentPath = getNestedParentPath(fieldPath, fields); + if (!nestedParentPath) { + return set({}, fieldPath, toStringArray(get(fieldPath, fields))); + } + + const subPath = fieldPath.replace(`${nestedParentPath}.`, ''); + const subFields = (get(nestedParentPath, fields) ?? []) as Fields[]; + return set( + {}, + nestedParentPath, + subFields.map((subField) => buildObjectRecursive(subPath, subField)) + ); +}; + +export const buildObjectForFieldPath = (fieldPath: string, hit: EventHit): Partial => { + if (has(fieldPath, hit._source)) { + const value = get(fieldPath, hit._source); + return set({}, fieldPath, toStringArray(value)); + } + + return buildObjectRecursive(fieldPath, hit.fields); +}; + +/** + * If a prefix of our full field path is present as a field, we know that our field is nested + */ +const getNestedParentPath = (fieldPath: string, fields: Fields | undefined): string | undefined => + fields && + Object.keys(fields).find((field) => field !== fieldPath && fieldPath.startsWith(`${field}.`)); + const mergeTimelineFieldsWithHit = async ( fieldName: string, flattenedFields: T, @@ -109,15 +138,12 @@ const mergeTimelineFieldsWithHit = async ( dataFields: readonly string[], ecsFields: readonly string[] ) => { - if (fieldName != null || dataFields.includes(fieldName)) { - const fieldNameAsArray = fieldName.split('.'); - const nestedParentFieldName = Object.keys(hit.fields ?? []).find((f) => { - return f === fieldNameAsArray.slice(0, f.split('.').length).join('.'); - }); + if (fieldName != null) { + const nestedParentPath = getNestedParentPath(fieldName, hit.fields); if ( + nestedParentPath != null || has(fieldName, hit._source) || has(fieldName, hit.fields) || - nestedParentFieldName != null || specialFields.includes(fieldName) ) { const objectWithProperty = { @@ -126,22 +152,13 @@ const mergeTimelineFieldsWithHit = async ( data: dataFields.includes(fieldName) ? [ ...get('node.data', flattenedFields), - ...(await getValuesFromFields(fieldName, hit, nestedParentFieldName)), + ...(await getValuesFromFields(fieldName, hit, nestedParentPath)), ] : get('node.data', flattenedFields), ecs: ecsFields.includes(fieldName) ? { ...get('node.ecs', flattenedFields), - // @ts-expect-error - ...fieldName.split('.').reduceRight( - // @ts-expect-error - (obj, next) => ({ [next]: obj }), - toStringArray( - has(fieldName, hit._source) - ? get(fieldName, hit._source) - : hit.fields[fieldName] - ) - ), + ...buildObjectForFieldPath(fieldName, hit), } : get('node.ecs', flattenedFields), }, From f4bc0d61a1d17926655abf78fd2e16e87cb47e7a Mon Sep 17 00:00:00 2001 From: Constance Date: Mon, 12 Apr 2021 15:55:04 -0700 Subject: [PATCH 03/90] Update create meta engine button to match create engine (#96884) --- .../app_search/components/engines/engines_overview.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx index 4d51012f2aa2ab..d7e2309fd2a07e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx @@ -134,8 +134,9 @@ export const EnginesOverview: React.FC = () => { {canManageEngines && ( From c218ba83976e1c0fc4d43d674b12180249cb7788 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 12 Apr 2021 17:02:03 -0600 Subject: [PATCH 04/90] [Maps] only allow sorting on numeric fields for tracks (#96877) --- .../classes/sources/es_geo_line_source/geo_line_form.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/geo_line_form.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/geo_line_form.tsx index bc743fe8d79b4f..081272f40b3444 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/geo_line_form.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/geo_line_form.tsx @@ -64,7 +64,12 @@ export function GeoLineForm(props: Props) { onChange={onSortFieldChange} fields={props.indexPattern.fields.filter((field) => { const isSplitField = props.splitField ? field.name === props.splitField : false; - return !isSplitField && field.sortable && !indexPatterns.isNestedField(field); + return ( + !isSplitField && + field.sortable && + !indexPatterns.isNestedField(field) && + ['number', 'date'].includes(field.type) + ); })} isClearable={false} /> From e3f5249c88bb8427eff7bedb17bf8ec8c837628c Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 13 Apr 2021 01:24:19 +0100 Subject: [PATCH 05/90] chore(NA): @kbn/pm new commands to support development on Bazel packages (#96465) * chore(NA): add warnings both to run and watch commands about Bazel built packages * chore(NA): add new commands to build and watch bazel packages * docs(NA): add documentation about how to deal with bazel packages * chore(NA): addressed majority of the feedback received except for improved error logging * chore(NA): disable ibazel info notification. * chore(NA): remove iBazel notification * chore(NA): remove iBazel notification - kbn pm dist * chore(NA): move show_results option to kbn-pm only * chore(NA): patch build bazel command to include packages target list * chore(NA): add pretty logging for elastic-datemath * chore(NA): remove double error output from commands ran with Bazel * fix(NA): include simple error message to preserve subprocess failure state * docs(NA): missing docs about how to independentely watch non bazel packages Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .bazelrc.common | 14 +- WORKSPACE.bazel | 3 - docs/developer/getting-started/index.asciidoc | 5 +- .../monorepo-packages.asciidoc | 66 + packages/elastic-datemath/BUILD.bazel | 1 + packages/kbn-pm/dist/index.js | 1910 +++++++++-------- packages/kbn-pm/src/commands/bootstrap.ts | 2 +- packages/kbn-pm/src/commands/build_bazel.ts | 22 + packages/kbn-pm/src/commands/index.ts | 4 + packages/kbn-pm/src/commands/run.ts | 10 +- packages/kbn-pm/src/commands/watch.ts | 9 +- packages/kbn-pm/src/commands/watch_bazel.ts | 25 + packages/kbn-pm/src/utils/bazel/run.ts | 34 +- 13 files changed, 1184 insertions(+), 921 deletions(-) create mode 100644 docs/developer/getting-started/monorepo-packages.asciidoc create mode 100644 packages/kbn-pm/src/commands/build_bazel.ts create mode 100644 packages/kbn-pm/src/commands/watch_bazel.ts diff --git a/.bazelrc.common b/.bazelrc.common index 20a41c4cde9a0d..115c0214b1a533 100644 --- a/.bazelrc.common +++ b/.bazelrc.common @@ -10,12 +10,13 @@ build --experimental_guard_against_concurrent_changes run --experimental_guard_against_concurrent_changes test --experimental_guard_against_concurrent_changes +query --experimental_guard_against_concurrent_changes ## Cache action outputs on disk so they persist across output_base and bazel shutdown (eg. changing branches) -build --disk_cache=~/.bazel-cache/disk-cache +common --disk_cache=~/.bazel-cache/disk-cache ## Bazel repo cache settings -build --repository_cache=~/.bazel-cache/repository-cache +common --repository_cache=~/.bazel-cache/repository-cache # Bazel will create symlinks from the workspace directory to output artifacts. # Build results will be placed in a directory called "bazel-bin" @@ -35,13 +36,16 @@ build --experimental_inprocess_symlink_creation # Incompatible flags to run with build --incompatible_no_implicit_file_export build --incompatible_restrict_string_escapes +query --incompatible_no_implicit_file_export +query --incompatible_restrict_string_escapes # Log configs ## different from default common --color=yes -build --show_task_finish -build --noshow_progress +common --noshow_progress +common --show_task_finish build --noshow_loading_progress +query --noshow_loading_progress build --show_result=0 # Specifies desired output mode for running tests. @@ -82,7 +86,7 @@ test:debug --test_output=streamed --test_strategy=exclusive --test_timeout=9999 run:debug --define=VERBOSE_LOGS=1 -- --node_options=--inspect-brk # The following option will change the build output of certain rules such as terser and may not be desirable in all cases # It will also output both the repo cache and action cache to a folder inside the repo -build:debug --compilation_mode=dbg --show_result=1 +build:debug --compilation_mode=dbg --show_result=0 --noshow_loading_progress --noshow_progress --show_task_finish # Turn off legacy external runfiles # This prevents accidentally depending on this feature, which Bazel will remove. diff --git a/WORKSPACE.bazel b/WORKSPACE.bazel index e74c646eedeaf8..bd4d8801b0d4e2 100644 --- a/WORKSPACE.bazel +++ b/WORKSPACE.bazel @@ -52,9 +52,6 @@ node_repositories( # NOTE: FORCE_COLOR env var forces colors on non tty mode yarn_install( name = "npm", - environment = { - "FORCE_COLOR": "True", - }, package_json = "//:package.json", yarn_lock = "//:yarn.lock", data = [ diff --git a/docs/developer/getting-started/index.asciidoc b/docs/developer/getting-started/index.asciidoc index 5a16dac66c822d..d5fe7ebf470382 100644 --- a/docs/developer/getting-started/index.asciidoc +++ b/docs/developer/getting-started/index.asciidoc @@ -66,7 +66,8 @@ yarn kbn bootstrap --force-install (You can also run `yarn kbn` to see the other available commands. For more info about this tool, see -{kib-repo}tree/{branch}/packages/kbn-pm[{kib-repo}tree/{branch}/packages/kbn-pm].) +{kib-repo}tree/{branch}/packages/kbn-pm[{kib-repo}tree/{branch}/packages/kbn-pm]. If you want more +information about how to actively develop over packages please read <>) When switching branches which use different versions of npm packages you may need to run: @@ -169,3 +170,5 @@ include::debugging.asciidoc[leveloffset=+1] include::building-kibana.asciidoc[leveloffset=+1] include::development-plugin-resources.asciidoc[leveloffset=+1] + +include::monorepo-packages.asciidoc[leveloffset=+1] diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc new file mode 100644 index 00000000000000..a95b357570278d --- /dev/null +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -0,0 +1,66 @@ +[[monorepo-packages]] +== {kib} Monorepo Packages + +Currently {kib} works as a monorepo composed by a core, plugins and packages. +The latest are located in a folder called `packages` and are pieces of software that +composes a set of features that can be isolated and reused across the entire repository. +They are also supposed to be able to imported just like any other `node_module`. + +Previously we relied solely on `@kbn/pm` to manage the development tools of those packages, but we are +now in the middle of migrating those responsibilities into Bazel. Every package already migrated +will contain in its root folder a `BUILD.bazel` file and other `build` and `watching` strategies should be used. + +Remember that any time you need to make sure the monorepo is ready to be used just run: + +[source,bash] +---- +yarn kbn bootstrap +---- + +[discrete] +=== Building Non Bazel Packages + +Non Bazel packages can be built independently with + +[source,bash] +---- +yarn kbn run build -i PACKAGE_NAME +---- + +[discrete] +=== Watching Non Bazel Packages + +Non Bazel packages can be watched independently with + +[source,bash] +---- +yarn kbn watch -i PACKAGE_NAME +---- + +[discrete] +=== Building Bazel Packages + +Bazel packages are built as a whole for now. You can use: + +[source,bash] +---- +yarn kbn build-bazel +---- + +[discrete] +=== Watching Bazel Packages + +Bazel packages are watched as a whole for now. You can use: + +[source,bash] +---- +yarn kbn watch-bazel +---- + + +[discrete] +=== List of Already Migrated Packages to Bazel + +- @elastic/datemath + + diff --git a/packages/elastic-datemath/BUILD.bazel b/packages/elastic-datemath/BUILD.bazel index 6a80556d4eed51..6b9a725e91bd4d 100644 --- a/packages/elastic-datemath/BUILD.bazel +++ b/packages/elastic-datemath/BUILD.bazel @@ -40,6 +40,7 @@ ts_config( ts_project( name = "tsc", + args = ['--pretty'], srcs = SRCS, deps = DEPS, declaration = True, diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index 7c5d0390d9fba2..af199fbbc27c29 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -94,7 +94,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _cli__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "run", function() { return _cli__WEBPACK_IMPORTED_MODULE_0__["run"]; }); -/* harmony import */ var _production__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(563); +/* harmony import */ var _production__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(565); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "buildBazelProductionProjects", function() { return _production__WEBPACK_IMPORTED_MODULE_1__["buildBazelProductionProjects"]; }); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "buildNonBazelProductionProjects", function() { return _production__WEBPACK_IMPORTED_MODULE_1__["buildNonBazelProductionProjects"]; }); @@ -108,7 +108,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _utils_package_json__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(251); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "transformDependencies", function() { return _utils_package_json__WEBPACK_IMPORTED_MODULE_4__["transformDependencies"]; }); -/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(562); +/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(564); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "getProjectPaths", function() { return _config__WEBPACK_IMPORTED_MODULE_5__["getProjectPaths"]; }); /* @@ -141,7 +141,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _kbn_dev_utils_tooling_log__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(5); /* harmony import */ var _kbn_dev_utils_tooling_log__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(_kbn_dev_utils_tooling_log__WEBPACK_IMPORTED_MODULE_3__); /* harmony import */ var _commands__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(128); -/* harmony import */ var _run__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(514); +/* harmony import */ var _run__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(516); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(246); /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one @@ -8833,10 +8833,12 @@ exports.ToolingLogCollectingWriter = ToolingLogCollectingWriter; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "commands", function() { return commands; }); /* harmony import */ var _bootstrap__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(129); -/* harmony import */ var _clean__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(478); -/* harmony import */ var _reset__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(510); -/* harmony import */ var _run__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(511); -/* harmony import */ var _watch__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(512); +/* harmony import */ var _build_bazel__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(478); +/* harmony import */ var _clean__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(479); +/* harmony import */ var _reset__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(511); +/* harmony import */ var _run__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(512); +/* harmony import */ var _watch__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(513); +/* harmony import */ var _watch_bazel__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(515); /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License @@ -8849,12 +8851,16 @@ __webpack_require__.r(__webpack_exports__); + + const commands = { bootstrap: _bootstrap__WEBPACK_IMPORTED_MODULE_0__["BootstrapCommand"], - clean: _clean__WEBPACK_IMPORTED_MODULE_1__["CleanCommand"], - reset: _reset__WEBPACK_IMPORTED_MODULE_2__["ResetCommand"], - run: _run__WEBPACK_IMPORTED_MODULE_3__["RunCommand"], - watch: _watch__WEBPACK_IMPORTED_MODULE_4__["WatchCommand"] + 'build-bazel': _build_bazel__WEBPACK_IMPORTED_MODULE_1__["BuildBazelCommand"], + clean: _clean__WEBPACK_IMPORTED_MODULE_2__["CleanCommand"], + reset: _reset__WEBPACK_IMPORTED_MODULE_3__["ResetCommand"], + run: _run__WEBPACK_IMPORTED_MODULE_4__["RunCommand"], + watch: _watch__WEBPACK_IMPORTED_MODULE_5__["WatchCommand"], + 'watch-bazel': _watch_bazel__WEBPACK_IMPORTED_MODULE_6__["WatchBazelCommand"] }; /***/ }), @@ -8933,7 +8939,7 @@ const BootstrapCommand = { await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_9__["runBazel"])(['run', '@nodejs//:yarn'], runOffline); } - await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_9__["runBazel"])(['build', '//packages:build'], runOffline); // Install monorepo npm dependencies outside of the Bazel managed ones + await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_9__["runBazel"])(['build', '//packages:build', '--show_result=1'], runOffline); // Install monorepo npm dependencies outside of the Bazel managed ones for (const batch of batchedNonBazelProjects) { for (const project of batch) { @@ -48141,6 +48147,8 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _run__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(376); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "runBazel", function() { return _run__WEBPACK_IMPORTED_MODULE_3__["runBazel"]; }); +/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "runIBazel", function() { return _run__WEBPACK_IMPORTED_MODULE_3__["runIBazel"]; }); + /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License @@ -48363,6 +48371,7 @@ async function installBazelTools(repoRootPath) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "runBazel", function() { return runBazel; }); +/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "runIBazel", function() { return runIBazel; }); /* harmony import */ var chalk__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(113); /* harmony import */ var chalk__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(chalk__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var rxjs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(8); @@ -48371,6 +48380,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _kbn_dev_utils_stdio__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(_kbn_dev_utils_stdio__WEBPACK_IMPORTED_MODULE_3__); /* harmony import */ var _child_process__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(319); /* harmony import */ var _log__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(246); +/* harmony import */ var _errors__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(249); function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } @@ -48390,7 +48400,9 @@ function _defineProperty(obj, key, value) { if (key in obj) { Object.definePrope -async function runBazel(bazelArgs, offline = false, runOpts = {}) { + + +async function runBazelCommandWithRunner(bazelCommandRunner, bazelArgs, offline = false, runOpts = {}) { // Force logs to pipe in order to control the output of them const bazelOpts = _objectSpread(_objectSpread({}, runOpts), {}, { stdio: 'pipe' @@ -48400,17 +48412,29 @@ async function runBazel(bazelArgs, offline = false, runOpts = {}) { bazelArgs.push('--config=offline'); } - const bazelProc = Object(_child_process__WEBPACK_IMPORTED_MODULE_4__["spawn"])('bazel', bazelArgs, bazelOpts); + const bazelProc = Object(_child_process__WEBPACK_IMPORTED_MODULE_4__["spawn"])(bazelCommandRunner, bazelArgs, bazelOpts); const bazelLogs$ = new rxjs__WEBPACK_IMPORTED_MODULE_1__["Subject"](); // Bazel outputs machine readable output into stdout and human readable output goes to stderr. // Therefore we need to get both. In order to get errors we need to parse the actual text line - const bazelLogSubscription = rxjs__WEBPACK_IMPORTED_MODULE_1__["merge"](Object(_kbn_dev_utils_stdio__WEBPACK_IMPORTED_MODULE_3__["observeLines"])(bazelProc.stdout).pipe(Object(rxjs_operators__WEBPACK_IMPORTED_MODULE_2__["tap"])(line => _log__WEBPACK_IMPORTED_MODULE_5__["log"].info(`${chalk__WEBPACK_IMPORTED_MODULE_0___default.a.cyan('[bazel]')} ${line}`))), Object(_kbn_dev_utils_stdio__WEBPACK_IMPORTED_MODULE_3__["observeLines"])(bazelProc.stderr).pipe(Object(rxjs_operators__WEBPACK_IMPORTED_MODULE_2__["tap"])(line => _log__WEBPACK_IMPORTED_MODULE_5__["log"].info(`${chalk__WEBPACK_IMPORTED_MODULE_0___default.a.cyan('[bazel]')} ${line}`)))).subscribe(bazelLogs$); // Wait for process and logs to finish, unsubscribing in the end + const bazelLogSubscription = rxjs__WEBPACK_IMPORTED_MODULE_1__["merge"](Object(_kbn_dev_utils_stdio__WEBPACK_IMPORTED_MODULE_3__["observeLines"])(bazelProc.stdout).pipe(Object(rxjs_operators__WEBPACK_IMPORTED_MODULE_2__["tap"])(line => _log__WEBPACK_IMPORTED_MODULE_5__["log"].info(`${chalk__WEBPACK_IMPORTED_MODULE_0___default.a.cyan(`[${bazelCommandRunner}]`)} ${line}`))), Object(_kbn_dev_utils_stdio__WEBPACK_IMPORTED_MODULE_3__["observeLines"])(bazelProc.stderr).pipe(Object(rxjs_operators__WEBPACK_IMPORTED_MODULE_2__["tap"])(line => _log__WEBPACK_IMPORTED_MODULE_5__["log"].info(`${chalk__WEBPACK_IMPORTED_MODULE_0___default.a.cyan(`[${bazelCommandRunner}]`)} ${line}`)))).subscribe(bazelLogs$); // Wait for process and logs to finish, unsubscribing in the end + + try { + await bazelProc; + } catch { + throw new _errors__WEBPACK_IMPORTED_MODULE_6__["CliError"](`The bazel command that was running failed to complete.`); + } - await bazelProc; await bazelLogs$.toPromise(); await bazelLogSubscription.unsubscribe(); } +async function runBazel(bazelArgs, offline = false, runOpts = {}) { + await runBazelCommandWithRunner('bazel', bazelArgs, offline, runOpts); +} +async function runIBazel(bazelArgs, offline = false, runOpts = {}) { + await runBazelCommandWithRunner('ibazel', bazelArgs, offline, runOpts); +} + /***/ }), /* 377 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { @@ -54550,6 +54574,36 @@ exports.observeReadable = observeReadable; /* 478 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { +"use strict"; +__webpack_require__.r(__webpack_exports__); +/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "BuildBazelCommand", function() { return BuildBazelCommand; }); +/* harmony import */ var _utils_bazel__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(372); +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +const BuildBazelCommand = { + description: 'Runs a build in the Bazel built packages', + name: 'build-bazel', + + async run(projects, projectGraph, { + options + }) { + const runOffline = (options === null || options === void 0 ? void 0 : options.offline) === true; // Call bazel with the target to build all available packages + + await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_0__["runBazel"])(['build', '//packages:build', '--show_result=1'], runOffline); + } + +}; + +/***/ }), +/* 479 */ +/***/ (function(module, __webpack_exports__, __webpack_require__) { + "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "CleanCommand", function() { return CleanCommand; }); @@ -54557,7 +54611,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var dedent__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(dedent__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(143); /* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(del__WEBPACK_IMPORTED_MODULE_1__); -/* harmony import */ var ora__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(479); +/* harmony import */ var ora__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(480); /* harmony import */ var ora__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(ora__WEBPACK_IMPORTED_MODULE_2__); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(4); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_3__); @@ -54660,20 +54714,20 @@ const CleanCommand = { }; /***/ }), -/* 479 */ +/* 480 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const readline = __webpack_require__(480); -const chalk = __webpack_require__(481); -const cliCursor = __webpack_require__(488); -const cliSpinners = __webpack_require__(490); -const logSymbols = __webpack_require__(492); -const stripAnsi = __webpack_require__(502); -const wcwidth = __webpack_require__(504); -const isInteractive = __webpack_require__(508); -const MuteStream = __webpack_require__(509); +const readline = __webpack_require__(481); +const chalk = __webpack_require__(482); +const cliCursor = __webpack_require__(489); +const cliSpinners = __webpack_require__(491); +const logSymbols = __webpack_require__(493); +const stripAnsi = __webpack_require__(503); +const wcwidth = __webpack_require__(505); +const isInteractive = __webpack_require__(509); +const MuteStream = __webpack_require__(510); const TEXT = Symbol('text'); const PREFIX_TEXT = Symbol('prefixText'); @@ -55026,23 +55080,23 @@ module.exports.promise = (action, options) => { /***/ }), -/* 480 */ +/* 481 */ /***/ (function(module, exports) { module.exports = require("readline"); /***/ }), -/* 481 */ +/* 482 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const ansiStyles = __webpack_require__(482); +const ansiStyles = __webpack_require__(483); const {stdout: stdoutColor, stderr: stderrColor} = __webpack_require__(120); const { stringReplaceAll, stringEncaseCRLFWithFirstIndex -} = __webpack_require__(486); +} = __webpack_require__(487); // `supportsColor.level` → `ansiStyles.color[name]` mapping const levelMapping = [ @@ -55243,7 +55297,7 @@ const chalkTag = (chalk, ...strings) => { } if (template === undefined) { - template = __webpack_require__(487); + template = __webpack_require__(488); } return template(chalk, parts.join('')); @@ -55272,7 +55326,7 @@ module.exports = chalk; /***/ }), -/* 482 */ +/* 483 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -55318,7 +55372,7 @@ const setLazyProperty = (object, property, get) => { let colorConvert; const makeDynamicStyles = (wrap, targetSpace, identity, isBackground) => { if (colorConvert === undefined) { - colorConvert = __webpack_require__(483); + colorConvert = __webpack_require__(484); } const offset = isBackground ? 10 : 0; @@ -55443,11 +55497,11 @@ Object.defineProperty(module, 'exports', { /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(115)(module))) /***/ }), -/* 483 */ +/* 484 */ /***/ (function(module, exports, __webpack_require__) { -const conversions = __webpack_require__(484); -const route = __webpack_require__(485); +const conversions = __webpack_require__(485); +const route = __webpack_require__(486); const convert = {}; @@ -55530,7 +55584,7 @@ module.exports = convert; /***/ }), -/* 484 */ +/* 485 */ /***/ (function(module, exports, __webpack_require__) { /* MIT license */ @@ -56375,10 +56429,10 @@ convert.rgb.gray = function (rgb) { /***/ }), -/* 485 */ +/* 486 */ /***/ (function(module, exports, __webpack_require__) { -const conversions = __webpack_require__(484); +const conversions = __webpack_require__(485); /* This function routes a model to all other models. @@ -56478,7 +56532,7 @@ module.exports = function (fromModel) { /***/ }), -/* 486 */ +/* 487 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -56524,7 +56578,7 @@ module.exports = { /***/ }), -/* 487 */ +/* 488 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -56665,12 +56719,12 @@ module.exports = (chalk, temporary) => { /***/ }), -/* 488 */ +/* 489 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const restoreCursor = __webpack_require__(489); +const restoreCursor = __webpack_require__(490); let isHidden = false; @@ -56707,7 +56761,7 @@ exports.toggle = (force, writableStream) => { /***/ }), -/* 489 */ +/* 490 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -56723,13 +56777,13 @@ module.exports = onetime(() => { /***/ }), -/* 490 */ +/* 491 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const spinners = Object.assign({}, __webpack_require__(491)); +const spinners = Object.assign({}, __webpack_require__(492)); const spinnersList = Object.keys(spinners); @@ -56747,18 +56801,18 @@ module.exports.default = spinners; /***/ }), -/* 491 */ +/* 492 */ /***/ (function(module) { module.exports = JSON.parse("{\"dots\":{\"interval\":80,\"frames\":[\"⠋\",\"⠙\",\"⠹\",\"⠸\",\"⠼\",\"⠴\",\"⠦\",\"⠧\",\"⠇\",\"⠏\"]},\"dots2\":{\"interval\":80,\"frames\":[\"⣾\",\"⣽\",\"⣻\",\"⢿\",\"⡿\",\"⣟\",\"⣯\",\"⣷\"]},\"dots3\":{\"interval\":80,\"frames\":[\"⠋\",\"⠙\",\"⠚\",\"⠞\",\"⠖\",\"⠦\",\"⠴\",\"⠲\",\"⠳\",\"⠓\"]},\"dots4\":{\"interval\":80,\"frames\":[\"⠄\",\"⠆\",\"⠇\",\"⠋\",\"⠙\",\"⠸\",\"⠰\",\"⠠\",\"⠰\",\"⠸\",\"⠙\",\"⠋\",\"⠇\",\"⠆\"]},\"dots5\":{\"interval\":80,\"frames\":[\"⠋\",\"⠙\",\"⠚\",\"⠒\",\"⠂\",\"⠂\",\"⠒\",\"⠲\",\"⠴\",\"⠦\",\"⠖\",\"⠒\",\"⠐\",\"⠐\",\"⠒\",\"⠓\",\"⠋\"]},\"dots6\":{\"interval\":80,\"frames\":[\"⠁\",\"⠉\",\"⠙\",\"⠚\",\"⠒\",\"⠂\",\"⠂\",\"⠒\",\"⠲\",\"⠴\",\"⠤\",\"⠄\",\"⠄\",\"⠤\",\"⠴\",\"⠲\",\"⠒\",\"⠂\",\"⠂\",\"⠒\",\"⠚\",\"⠙\",\"⠉\",\"⠁\"]},\"dots7\":{\"interval\":80,\"frames\":[\"⠈\",\"⠉\",\"⠋\",\"⠓\",\"⠒\",\"⠐\",\"⠐\",\"⠒\",\"⠖\",\"⠦\",\"⠤\",\"⠠\",\"⠠\",\"⠤\",\"⠦\",\"⠖\",\"⠒\",\"⠐\",\"⠐\",\"⠒\",\"⠓\",\"⠋\",\"⠉\",\"⠈\"]},\"dots8\":{\"interval\":80,\"frames\":[\"⠁\",\"⠁\",\"⠉\",\"⠙\",\"⠚\",\"⠒\",\"⠂\",\"⠂\",\"⠒\",\"⠲\",\"⠴\",\"⠤\",\"⠄\",\"⠄\",\"⠤\",\"⠠\",\"⠠\",\"⠤\",\"⠦\",\"⠖\",\"⠒\",\"⠐\",\"⠐\",\"⠒\",\"⠓\",\"⠋\",\"⠉\",\"⠈\",\"⠈\"]},\"dots9\":{\"interval\":80,\"frames\":[\"⢹\",\"⢺\",\"⢼\",\"⣸\",\"⣇\",\"⡧\",\"⡗\",\"⡏\"]},\"dots10\":{\"interval\":80,\"frames\":[\"⢄\",\"⢂\",\"⢁\",\"⡁\",\"⡈\",\"⡐\",\"⡠\"]},\"dots11\":{\"interval\":100,\"frames\":[\"⠁\",\"⠂\",\"⠄\",\"⡀\",\"⢀\",\"⠠\",\"⠐\",\"⠈\"]},\"dots12\":{\"interval\":80,\"frames\":[\"⢀⠀\",\"⡀⠀\",\"⠄⠀\",\"⢂⠀\",\"⡂⠀\",\"⠅⠀\",\"⢃⠀\",\"⡃⠀\",\"⠍⠀\",\"⢋⠀\",\"⡋⠀\",\"⠍⠁\",\"⢋⠁\",\"⡋⠁\",\"⠍⠉\",\"⠋⠉\",\"⠋⠉\",\"⠉⠙\",\"⠉⠙\",\"⠉⠩\",\"⠈⢙\",\"⠈⡙\",\"⢈⠩\",\"⡀⢙\",\"⠄⡙\",\"⢂⠩\",\"⡂⢘\",\"⠅⡘\",\"⢃⠨\",\"⡃⢐\",\"⠍⡐\",\"⢋⠠\",\"⡋⢀\",\"⠍⡁\",\"⢋⠁\",\"⡋⠁\",\"⠍⠉\",\"⠋⠉\",\"⠋⠉\",\"⠉⠙\",\"⠉⠙\",\"⠉⠩\",\"⠈⢙\",\"⠈⡙\",\"⠈⠩\",\"⠀⢙\",\"⠀⡙\",\"⠀⠩\",\"⠀⢘\",\"⠀⡘\",\"⠀⠨\",\"⠀⢐\",\"⠀⡐\",\"⠀⠠\",\"⠀⢀\",\"⠀⡀\"]},\"dots8Bit\":{\"interval\":80,\"frames\":[\"⠀\",\"⠁\",\"⠂\",\"⠃\",\"⠄\",\"⠅\",\"⠆\",\"⠇\",\"⡀\",\"⡁\",\"⡂\",\"⡃\",\"⡄\",\"⡅\",\"⡆\",\"⡇\",\"⠈\",\"⠉\",\"⠊\",\"⠋\",\"⠌\",\"⠍\",\"⠎\",\"⠏\",\"⡈\",\"⡉\",\"⡊\",\"⡋\",\"⡌\",\"⡍\",\"⡎\",\"⡏\",\"⠐\",\"⠑\",\"⠒\",\"⠓\",\"⠔\",\"⠕\",\"⠖\",\"⠗\",\"⡐\",\"⡑\",\"⡒\",\"⡓\",\"⡔\",\"⡕\",\"⡖\",\"⡗\",\"⠘\",\"⠙\",\"⠚\",\"⠛\",\"⠜\",\"⠝\",\"⠞\",\"⠟\",\"⡘\",\"⡙\",\"⡚\",\"⡛\",\"⡜\",\"⡝\",\"⡞\",\"⡟\",\"⠠\",\"⠡\",\"⠢\",\"⠣\",\"⠤\",\"⠥\",\"⠦\",\"⠧\",\"⡠\",\"⡡\",\"⡢\",\"⡣\",\"⡤\",\"⡥\",\"⡦\",\"⡧\",\"⠨\",\"⠩\",\"⠪\",\"⠫\",\"⠬\",\"⠭\",\"⠮\",\"⠯\",\"⡨\",\"⡩\",\"⡪\",\"⡫\",\"⡬\",\"⡭\",\"⡮\",\"⡯\",\"⠰\",\"⠱\",\"⠲\",\"⠳\",\"⠴\",\"⠵\",\"⠶\",\"⠷\",\"⡰\",\"⡱\",\"⡲\",\"⡳\",\"⡴\",\"⡵\",\"⡶\",\"⡷\",\"⠸\",\"⠹\",\"⠺\",\"⠻\",\"⠼\",\"⠽\",\"⠾\",\"⠿\",\"⡸\",\"⡹\",\"⡺\",\"⡻\",\"⡼\",\"⡽\",\"⡾\",\"⡿\",\"⢀\",\"⢁\",\"⢂\",\"⢃\",\"⢄\",\"⢅\",\"⢆\",\"⢇\",\"⣀\",\"⣁\",\"⣂\",\"⣃\",\"⣄\",\"⣅\",\"⣆\",\"⣇\",\"⢈\",\"⢉\",\"⢊\",\"⢋\",\"⢌\",\"⢍\",\"⢎\",\"⢏\",\"⣈\",\"⣉\",\"⣊\",\"⣋\",\"⣌\",\"⣍\",\"⣎\",\"⣏\",\"⢐\",\"⢑\",\"⢒\",\"⢓\",\"⢔\",\"⢕\",\"⢖\",\"⢗\",\"⣐\",\"⣑\",\"⣒\",\"⣓\",\"⣔\",\"⣕\",\"⣖\",\"⣗\",\"⢘\",\"⢙\",\"⢚\",\"⢛\",\"⢜\",\"⢝\",\"⢞\",\"⢟\",\"⣘\",\"⣙\",\"⣚\",\"⣛\",\"⣜\",\"⣝\",\"⣞\",\"⣟\",\"⢠\",\"⢡\",\"⢢\",\"⢣\",\"⢤\",\"⢥\",\"⢦\",\"⢧\",\"⣠\",\"⣡\",\"⣢\",\"⣣\",\"⣤\",\"⣥\",\"⣦\",\"⣧\",\"⢨\",\"⢩\",\"⢪\",\"⢫\",\"⢬\",\"⢭\",\"⢮\",\"⢯\",\"⣨\",\"⣩\",\"⣪\",\"⣫\",\"⣬\",\"⣭\",\"⣮\",\"⣯\",\"⢰\",\"⢱\",\"⢲\",\"⢳\",\"⢴\",\"⢵\",\"⢶\",\"⢷\",\"⣰\",\"⣱\",\"⣲\",\"⣳\",\"⣴\",\"⣵\",\"⣶\",\"⣷\",\"⢸\",\"⢹\",\"⢺\",\"⢻\",\"⢼\",\"⢽\",\"⢾\",\"⢿\",\"⣸\",\"⣹\",\"⣺\",\"⣻\",\"⣼\",\"⣽\",\"⣾\",\"⣿\"]},\"line\":{\"interval\":130,\"frames\":[\"-\",\"\\\\\",\"|\",\"/\"]},\"line2\":{\"interval\":100,\"frames\":[\"⠂\",\"-\",\"–\",\"—\",\"–\",\"-\"]},\"pipe\":{\"interval\":100,\"frames\":[\"┤\",\"┘\",\"┴\",\"└\",\"├\",\"┌\",\"┬\",\"┐\"]},\"simpleDots\":{\"interval\":400,\"frames\":[\". \",\".. \",\"...\",\" \"]},\"simpleDotsScrolling\":{\"interval\":200,\"frames\":[\". \",\".. \",\"...\",\" ..\",\" .\",\" \"]},\"star\":{\"interval\":70,\"frames\":[\"✶\",\"✸\",\"✹\",\"✺\",\"✹\",\"✷\"]},\"star2\":{\"interval\":80,\"frames\":[\"+\",\"x\",\"*\"]},\"flip\":{\"interval\":70,\"frames\":[\"_\",\"_\",\"_\",\"-\",\"`\",\"`\",\"'\",\"´\",\"-\",\"_\",\"_\",\"_\"]},\"hamburger\":{\"interval\":100,\"frames\":[\"☱\",\"☲\",\"☴\"]},\"growVertical\":{\"interval\":120,\"frames\":[\"▁\",\"▃\",\"▄\",\"▅\",\"▆\",\"▇\",\"▆\",\"▅\",\"▄\",\"▃\"]},\"growHorizontal\":{\"interval\":120,\"frames\":[\"▏\",\"▎\",\"▍\",\"▌\",\"▋\",\"▊\",\"▉\",\"▊\",\"▋\",\"▌\",\"▍\",\"▎\"]},\"balloon\":{\"interval\":140,\"frames\":[\" \",\".\",\"o\",\"O\",\"@\",\"*\",\" \"]},\"balloon2\":{\"interval\":120,\"frames\":[\".\",\"o\",\"O\",\"°\",\"O\",\"o\",\".\"]},\"noise\":{\"interval\":100,\"frames\":[\"▓\",\"▒\",\"░\"]},\"bounce\":{\"interval\":120,\"frames\":[\"⠁\",\"⠂\",\"⠄\",\"⠂\"]},\"boxBounce\":{\"interval\":120,\"frames\":[\"▖\",\"▘\",\"▝\",\"▗\"]},\"boxBounce2\":{\"interval\":100,\"frames\":[\"▌\",\"▀\",\"▐\",\"▄\"]},\"triangle\":{\"interval\":50,\"frames\":[\"◢\",\"◣\",\"◤\",\"◥\"]},\"arc\":{\"interval\":100,\"frames\":[\"◜\",\"◠\",\"◝\",\"◞\",\"◡\",\"◟\"]},\"circle\":{\"interval\":120,\"frames\":[\"◡\",\"⊙\",\"◠\"]},\"squareCorners\":{\"interval\":180,\"frames\":[\"◰\",\"◳\",\"◲\",\"◱\"]},\"circleQuarters\":{\"interval\":120,\"frames\":[\"◴\",\"◷\",\"◶\",\"◵\"]},\"circleHalves\":{\"interval\":50,\"frames\":[\"◐\",\"◓\",\"◑\",\"◒\"]},\"squish\":{\"interval\":100,\"frames\":[\"╫\",\"╪\"]},\"toggle\":{\"interval\":250,\"frames\":[\"⊶\",\"⊷\"]},\"toggle2\":{\"interval\":80,\"frames\":[\"▫\",\"▪\"]},\"toggle3\":{\"interval\":120,\"frames\":[\"□\",\"■\"]},\"toggle4\":{\"interval\":100,\"frames\":[\"■\",\"□\",\"▪\",\"▫\"]},\"toggle5\":{\"interval\":100,\"frames\":[\"▮\",\"▯\"]},\"toggle6\":{\"interval\":300,\"frames\":[\"ဝ\",\"၀\"]},\"toggle7\":{\"interval\":80,\"frames\":[\"⦾\",\"⦿\"]},\"toggle8\":{\"interval\":100,\"frames\":[\"◍\",\"◌\"]},\"toggle9\":{\"interval\":100,\"frames\":[\"◉\",\"◎\"]},\"toggle10\":{\"interval\":100,\"frames\":[\"㊂\",\"㊀\",\"㊁\"]},\"toggle11\":{\"interval\":50,\"frames\":[\"⧇\",\"⧆\"]},\"toggle12\":{\"interval\":120,\"frames\":[\"☗\",\"☖\"]},\"toggle13\":{\"interval\":80,\"frames\":[\"=\",\"*\",\"-\"]},\"arrow\":{\"interval\":100,\"frames\":[\"←\",\"↖\",\"↑\",\"↗\",\"→\",\"↘\",\"↓\",\"↙\"]},\"arrow2\":{\"interval\":80,\"frames\":[\"⬆️ \",\"↗️ \",\"➡️ \",\"↘️ \",\"⬇️ \",\"↙️ \",\"⬅️ \",\"↖️ \"]},\"arrow3\":{\"interval\":120,\"frames\":[\"▹▹▹▹▹\",\"▸▹▹▹▹\",\"▹▸▹▹▹\",\"▹▹▸▹▹\",\"▹▹▹▸▹\",\"▹▹▹▹▸\"]},\"bouncingBar\":{\"interval\":80,\"frames\":[\"[ ]\",\"[= ]\",\"[== ]\",\"[=== ]\",\"[ ===]\",\"[ ==]\",\"[ =]\",\"[ ]\",\"[ =]\",\"[ ==]\",\"[ ===]\",\"[====]\",\"[=== ]\",\"[== ]\",\"[= ]\"]},\"bouncingBall\":{\"interval\":80,\"frames\":[\"( ● )\",\"( ● )\",\"( ● )\",\"( ● )\",\"( ●)\",\"( ● )\",\"( ● )\",\"( ● )\",\"( ● )\",\"(● )\"]},\"smiley\":{\"interval\":200,\"frames\":[\"😄 \",\"😝 \"]},\"monkey\":{\"interval\":300,\"frames\":[\"🙈 \",\"🙈 \",\"🙉 \",\"🙊 \"]},\"hearts\":{\"interval\":100,\"frames\":[\"💛 \",\"💙 \",\"💜 \",\"💚 \",\"❤️ \"]},\"clock\":{\"interval\":100,\"frames\":[\"🕛 \",\"🕐 \",\"🕑 \",\"🕒 \",\"🕓 \",\"🕔 \",\"🕕 \",\"🕖 \",\"🕗 \",\"🕘 \",\"🕙 \",\"🕚 \"]},\"earth\":{\"interval\":180,\"frames\":[\"🌍 \",\"🌎 \",\"🌏 \"]},\"material\":{\"interval\":17,\"frames\":[\"█▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"██▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"███▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"████▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"██████▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"██████▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"███████▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"████████▁▁▁▁▁▁▁▁▁▁▁▁\",\"█████████▁▁▁▁▁▁▁▁▁▁▁\",\"█████████▁▁▁▁▁▁▁▁▁▁▁\",\"██████████▁▁▁▁▁▁▁▁▁▁\",\"███████████▁▁▁▁▁▁▁▁▁\",\"█████████████▁▁▁▁▁▁▁\",\"██████████████▁▁▁▁▁▁\",\"██████████████▁▁▁▁▁▁\",\"▁██████████████▁▁▁▁▁\",\"▁██████████████▁▁▁▁▁\",\"▁██████████████▁▁▁▁▁\",\"▁▁██████████████▁▁▁▁\",\"▁▁▁██████████████▁▁▁\",\"▁▁▁▁█████████████▁▁▁\",\"▁▁▁▁██████████████▁▁\",\"▁▁▁▁██████████████▁▁\",\"▁▁▁▁▁██████████████▁\",\"▁▁▁▁▁██████████████▁\",\"▁▁▁▁▁██████████████▁\",\"▁▁▁▁▁▁██████████████\",\"▁▁▁▁▁▁██████████████\",\"▁▁▁▁▁▁▁█████████████\",\"▁▁▁▁▁▁▁█████████████\",\"▁▁▁▁▁▁▁▁████████████\",\"▁▁▁▁▁▁▁▁████████████\",\"▁▁▁▁▁▁▁▁▁███████████\",\"▁▁▁▁▁▁▁▁▁███████████\",\"▁▁▁▁▁▁▁▁▁▁██████████\",\"▁▁▁▁▁▁▁▁▁▁██████████\",\"▁▁▁▁▁▁▁▁▁▁▁▁████████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁███████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁██████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█████\",\"█▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁████\",\"██▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁███\",\"██▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁███\",\"███▁▁▁▁▁▁▁▁▁▁▁▁▁▁███\",\"████▁▁▁▁▁▁▁▁▁▁▁▁▁▁██\",\"█████▁▁▁▁▁▁▁▁▁▁▁▁▁▁█\",\"█████▁▁▁▁▁▁▁▁▁▁▁▁▁▁█\",\"██████▁▁▁▁▁▁▁▁▁▁▁▁▁█\",\"████████▁▁▁▁▁▁▁▁▁▁▁▁\",\"█████████▁▁▁▁▁▁▁▁▁▁▁\",\"█████████▁▁▁▁▁▁▁▁▁▁▁\",\"█████████▁▁▁▁▁▁▁▁▁▁▁\",\"█████████▁▁▁▁▁▁▁▁▁▁▁\",\"███████████▁▁▁▁▁▁▁▁▁\",\"████████████▁▁▁▁▁▁▁▁\",\"████████████▁▁▁▁▁▁▁▁\",\"██████████████▁▁▁▁▁▁\",\"██████████████▁▁▁▁▁▁\",\"▁██████████████▁▁▁▁▁\",\"▁██████████████▁▁▁▁▁\",\"▁▁▁█████████████▁▁▁▁\",\"▁▁▁▁▁████████████▁▁▁\",\"▁▁▁▁▁████████████▁▁▁\",\"▁▁▁▁▁▁███████████▁▁▁\",\"▁▁▁▁▁▁▁▁█████████▁▁▁\",\"▁▁▁▁▁▁▁▁█████████▁▁▁\",\"▁▁▁▁▁▁▁▁▁█████████▁▁\",\"▁▁▁▁▁▁▁▁▁█████████▁▁\",\"▁▁▁▁▁▁▁▁▁▁█████████▁\",\"▁▁▁▁▁▁▁▁▁▁▁████████▁\",\"▁▁▁▁▁▁▁▁▁▁▁████████▁\",\"▁▁▁▁▁▁▁▁▁▁▁▁███████▁\",\"▁▁▁▁▁▁▁▁▁▁▁▁███████▁\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁███████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁███████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁███\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁███\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁██\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁██\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁██\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\"]},\"moon\":{\"interval\":80,\"frames\":[\"🌑 \",\"🌒 \",\"🌓 \",\"🌔 \",\"🌕 \",\"🌖 \",\"🌗 \",\"🌘 \"]},\"runner\":{\"interval\":140,\"frames\":[\"🚶 \",\"🏃 \"]},\"pong\":{\"interval\":80,\"frames\":[\"▐⠂ ▌\",\"▐⠈ ▌\",\"▐ ⠂ ▌\",\"▐ ⠠ ▌\",\"▐ ⡀ ▌\",\"▐ ⠠ ▌\",\"▐ ⠂ ▌\",\"▐ ⠈ ▌\",\"▐ ⠂ ▌\",\"▐ ⠠ ▌\",\"▐ ⡀ ▌\",\"▐ ⠠ ▌\",\"▐ ⠂ ▌\",\"▐ ⠈ ▌\",\"▐ ⠂▌\",\"▐ ⠠▌\",\"▐ ⡀▌\",\"▐ ⠠ ▌\",\"▐ ⠂ ▌\",\"▐ ⠈ ▌\",\"▐ ⠂ ▌\",\"▐ ⠠ ▌\",\"▐ ⡀ ▌\",\"▐ ⠠ ▌\",\"▐ ⠂ ▌\",\"▐ ⠈ ▌\",\"▐ ⠂ ▌\",\"▐ ⠠ ▌\",\"▐ ⡀ ▌\",\"▐⠠ ▌\"]},\"shark\":{\"interval\":120,\"frames\":[\"▐|\\\\____________▌\",\"▐_|\\\\___________▌\",\"▐__|\\\\__________▌\",\"▐___|\\\\_________▌\",\"▐____|\\\\________▌\",\"▐_____|\\\\_______▌\",\"▐______|\\\\______▌\",\"▐_______|\\\\_____▌\",\"▐________|\\\\____▌\",\"▐_________|\\\\___▌\",\"▐__________|\\\\__▌\",\"▐___________|\\\\_▌\",\"▐____________|\\\\▌\",\"▐____________/|▌\",\"▐___________/|_▌\",\"▐__________/|__▌\",\"▐_________/|___▌\",\"▐________/|____▌\",\"▐_______/|_____▌\",\"▐______/|______▌\",\"▐_____/|_______▌\",\"▐____/|________▌\",\"▐___/|_________▌\",\"▐__/|__________▌\",\"▐_/|___________▌\",\"▐/|____________▌\"]},\"dqpb\":{\"interval\":100,\"frames\":[\"d\",\"q\",\"p\",\"b\"]},\"weather\":{\"interval\":100,\"frames\":[\"☀️ \",\"☀️ \",\"☀️ \",\"🌤 \",\"⛅️ \",\"🌥 \",\"☁️ \",\"🌧 \",\"🌨 \",\"🌧 \",\"🌨 \",\"🌧 \",\"🌨 \",\"⛈ \",\"🌨 \",\"🌧 \",\"🌨 \",\"☁️ \",\"🌥 \",\"⛅️ \",\"🌤 \",\"☀️ \",\"☀️ \"]},\"christmas\":{\"interval\":400,\"frames\":[\"🌲\",\"🎄\"]},\"grenade\":{\"interval\":80,\"frames\":[\"، \",\"′ \",\" ´ \",\" ‾ \",\" ⸌\",\" ⸊\",\" |\",\" ⁎\",\" ⁕\",\" ෴ \",\" ⁓\",\" \",\" \",\" \"]},\"point\":{\"interval\":125,\"frames\":[\"∙∙∙\",\"●∙∙\",\"∙●∙\",\"∙∙●\",\"∙∙∙\"]},\"layer\":{\"interval\":150,\"frames\":[\"-\",\"=\",\"≡\"]},\"betaWave\":{\"interval\":80,\"frames\":[\"ρββββββ\",\"βρβββββ\",\"ββρββββ\",\"βββρβββ\",\"ββββρββ\",\"βββββρβ\",\"ββββββρ\"]},\"aesthetic\":{\"interval\":80,\"frames\":[\"▰▱▱▱▱▱▱\",\"▰▰▱▱▱▱▱\",\"▰▰▰▱▱▱▱\",\"▰▰▰▰▱▱▱\",\"▰▰▰▰▰▱▱\",\"▰▰▰▰▰▰▱\",\"▰▰▰▰▰▰▰\",\"▰▱▱▱▱▱▱\"]}}"); /***/ }), -/* 492 */ +/* 493 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const chalk = __webpack_require__(493); +const chalk = __webpack_require__(494); const isSupported = process.platform !== 'win32' || process.env.CI || process.env.TERM === 'xterm-256color'; @@ -56780,16 +56834,16 @@ module.exports = isSupported ? main : fallbacks; /***/ }), -/* 493 */ +/* 494 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const escapeStringRegexp = __webpack_require__(265); -const ansiStyles = __webpack_require__(494); -const stdoutColor = __webpack_require__(499).stdout; +const ansiStyles = __webpack_require__(495); +const stdoutColor = __webpack_require__(500).stdout; -const template = __webpack_require__(501); +const template = __webpack_require__(502); const isSimpleWindowsTerm = process.platform === 'win32' && !(process.env.TERM || '').toLowerCase().startsWith('xterm'); @@ -57015,12 +57069,12 @@ module.exports.default = module.exports; // For TypeScript /***/ }), -/* 494 */ +/* 495 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; /* WEBPACK VAR INJECTION */(function(module) { -const colorConvert = __webpack_require__(495); +const colorConvert = __webpack_require__(496); const wrapAnsi16 = (fn, offset) => function () { const code = fn.apply(colorConvert, arguments); @@ -57188,11 +57242,11 @@ Object.defineProperty(module, 'exports', { /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(115)(module))) /***/ }), -/* 495 */ +/* 496 */ /***/ (function(module, exports, __webpack_require__) { -var conversions = __webpack_require__(496); -var route = __webpack_require__(498); +var conversions = __webpack_require__(497); +var route = __webpack_require__(499); var convert = {}; @@ -57272,11 +57326,11 @@ module.exports = convert; /***/ }), -/* 496 */ +/* 497 */ /***/ (function(module, exports, __webpack_require__) { /* MIT license */ -var cssKeywords = __webpack_require__(497); +var cssKeywords = __webpack_require__(498); // NOTE: conversions should only return primitive values (i.e. arrays, or // values that give correct `typeof` results). @@ -58146,7 +58200,7 @@ convert.rgb.gray = function (rgb) { /***/ }), -/* 497 */ +/* 498 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -58305,10 +58359,10 @@ module.exports = { /***/ }), -/* 498 */ +/* 499 */ /***/ (function(module, exports, __webpack_require__) { -var conversions = __webpack_require__(496); +var conversions = __webpack_require__(497); /* this function routes a model to all other models. @@ -58408,13 +58462,13 @@ module.exports = function (fromModel) { /***/ }), -/* 499 */ +/* 500 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const os = __webpack_require__(121); -const hasFlag = __webpack_require__(500); +const hasFlag = __webpack_require__(501); const env = process.env; @@ -58546,7 +58600,7 @@ module.exports = { /***/ }), -/* 500 */ +/* 501 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -58561,7 +58615,7 @@ module.exports = (flag, argv) => { /***/ }), -/* 501 */ +/* 502 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -58696,18 +58750,18 @@ module.exports = (chalk, tmp) => { /***/ }), -/* 502 */ +/* 503 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const ansiRegex = __webpack_require__(503); +const ansiRegex = __webpack_require__(504); module.exports = string => typeof string === 'string' ? string.replace(ansiRegex(), '') : string; /***/ }), -/* 503 */ +/* 504 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -58724,14 +58778,14 @@ module.exports = ({onlyFirst = false} = {}) => { /***/ }), -/* 504 */ +/* 505 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var defaults = __webpack_require__(505) -var combining = __webpack_require__(507) +var defaults = __webpack_require__(506) +var combining = __webpack_require__(508) var DEFAULTS = { nul: 0, @@ -58830,10 +58884,10 @@ function bisearch(ucs) { /***/ }), -/* 505 */ +/* 506 */ /***/ (function(module, exports, __webpack_require__) { -var clone = __webpack_require__(506); +var clone = __webpack_require__(507); module.exports = function(options, defaults) { options = options || {}; @@ -58848,7 +58902,7 @@ module.exports = function(options, defaults) { }; /***/ }), -/* 506 */ +/* 507 */ /***/ (function(module, exports, __webpack_require__) { var clone = (function() { @@ -59020,7 +59074,7 @@ if ( true && module.exports) { /***/ }), -/* 507 */ +/* 508 */ /***/ (function(module, exports) { module.exports = [ @@ -59076,7 +59130,7 @@ module.exports = [ /***/ }), -/* 508 */ +/* 509 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59092,7 +59146,7 @@ module.exports = ({stream = process.stdout} = {}) => { /***/ }), -/* 509 */ +/* 510 */ /***/ (function(module, exports, __webpack_require__) { var Stream = __webpack_require__(138) @@ -59243,7 +59297,7 @@ MuteStream.prototype.close = proxy('close') /***/ }), -/* 510 */ +/* 511 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -59253,7 +59307,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var dedent__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(dedent__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(143); /* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(del__WEBPACK_IMPORTED_MODULE_1__); -/* harmony import */ var ora__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(479); +/* harmony import */ var ora__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(480); /* harmony import */ var ora__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(ora__WEBPACK_IMPORTED_MODULE_2__); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(4); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_3__); @@ -59362,16 +59416,18 @@ const ResetCommand = { }; /***/ }), -/* 511 */ +/* 512 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "RunCommand", function() { return RunCommand; }); -/* harmony import */ var _utils_errors__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(249); -/* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(246); -/* harmony import */ var _utils_parallelize__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(247); -/* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(248); +/* harmony import */ var dedent__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2); +/* harmony import */ var dedent__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(dedent__WEBPACK_IMPORTED_MODULE_0__); +/* harmony import */ var _utils_errors__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(249); +/* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(246); +/* harmony import */ var _utils_parallelize__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(247); +/* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(248); /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License @@ -59383,53 +59439,61 @@ __webpack_require__.r(__webpack_exports__); + const RunCommand = { - description: 'Run script defined in package.json in each package that contains that script.', + description: 'Run script defined in package.json in each package that contains that script (only works on packages not using Bazel yet)', name: 'run', async run(projects, projectGraph, { extraArgs, options }) { - const batchedProjects = Object(_utils_projects__WEBPACK_IMPORTED_MODULE_3__["topologicallyBatchProjects"])(projects, projectGraph); + _utils_log__WEBPACK_IMPORTED_MODULE_2__["log"].warning(dedent__WEBPACK_IMPORTED_MODULE_0___default.a` + We are migrating packages into the Bazel build system and we will no longer support running npm scripts on + packages using 'yarn kbn run' on Bazel built packages. If the package you are trying to act on contains a + BUILD.bazel file please just use 'yarn kbn build-bazel' to build it or 'yarn kbn watch-bazel' to watch it + `); + const batchedProjects = Object(_utils_projects__WEBPACK_IMPORTED_MODULE_4__["topologicallyBatchProjects"])(projects, projectGraph); if (extraArgs.length === 0) { - throw new _utils_errors__WEBPACK_IMPORTED_MODULE_0__["CliError"]('No script specified'); + throw new _utils_errors__WEBPACK_IMPORTED_MODULE_1__["CliError"]('No script specified'); } const scriptName = extraArgs[0]; const scriptArgs = extraArgs.slice(1); - await Object(_utils_parallelize__WEBPACK_IMPORTED_MODULE_2__["parallelizeBatches"])(batchedProjects, async project => { + await Object(_utils_parallelize__WEBPACK_IMPORTED_MODULE_3__["parallelizeBatches"])(batchedProjects, async project => { if (!project.hasScript(scriptName)) { if (!!options['skip-missing']) { return; } - throw new _utils_errors__WEBPACK_IMPORTED_MODULE_0__["CliError"](`[${project.name}] no "${scriptName}" script defined. To skip packages without the "${scriptName}" script pass --skip-missing`); + throw new _utils_errors__WEBPACK_IMPORTED_MODULE_1__["CliError"](`[${project.name}] no "${scriptName}" script defined. To skip packages without the "${scriptName}" script pass --skip-missing`); } - _utils_log__WEBPACK_IMPORTED_MODULE_1__["log"].info(`[${project.name}] running "${scriptName}" script`); + _utils_log__WEBPACK_IMPORTED_MODULE_2__["log"].info(`[${project.name}] running "${scriptName}" script`); await project.runScriptStreaming(scriptName, { args: scriptArgs }); - _utils_log__WEBPACK_IMPORTED_MODULE_1__["log"].success(`[${project.name}] complete`); + _utils_log__WEBPACK_IMPORTED_MODULE_2__["log"].success(`[${project.name}] complete`); }); } }; /***/ }), -/* 512 */ +/* 513 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "WatchCommand", function() { return WatchCommand; }); -/* harmony import */ var _utils_errors__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(249); -/* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(246); -/* harmony import */ var _utils_parallelize__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(247); -/* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(248); -/* harmony import */ var _utils_watch__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(513); +/* harmony import */ var dedent__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2); +/* harmony import */ var dedent__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(dedent__WEBPACK_IMPORTED_MODULE_0__); +/* harmony import */ var _utils_errors__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(249); +/* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(246); +/* harmony import */ var _utils_parallelize__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(247); +/* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(248); +/* harmony import */ var _utils_watch__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(514); /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License @@ -59443,6 +59507,7 @@ __webpack_require__.r(__webpack_exports__); + /** * Name of the script in the package/project package.json file to run during `kbn watch`. */ @@ -59464,10 +59529,14 @@ const kibanaProjectName = 'kibana'; */ const WatchCommand = { - description: 'Runs `kbn:watch` script for every project.', + description: 'Runs `kbn:watch` script for every project (only works on packages not using Bazel yet)', name: 'watch', async run(projects, projectGraph) { + _utils_log__WEBPACK_IMPORTED_MODULE_2__["log"].warning(dedent__WEBPACK_IMPORTED_MODULE_0___default.a` + We are migrating packages into the Bazel build system. If the package you are trying to watch + contains a BUILD.bazel file please just use 'yarn kbn watch-bazel' + `); const projectsToWatch = new Map(); for (const project of projects.values()) { @@ -59478,33 +59547,33 @@ const WatchCommand = { } if (projectsToWatch.size === 0) { - throw new _utils_errors__WEBPACK_IMPORTED_MODULE_0__["CliError"](`There are no projects to watch found. Make sure that projects define 'kbn:watch' script in 'package.json'.`); + throw new _utils_errors__WEBPACK_IMPORTED_MODULE_1__["CliError"](`There are no projects to watch found. Make sure that projects define 'kbn:watch' script in 'package.json'.`); } const projectNames = Array.from(projectsToWatch.keys()); - _utils_log__WEBPACK_IMPORTED_MODULE_1__["log"].info(`Running ${watchScriptName} scripts for [${projectNames.join(', ')}].`); // Kibana should always be run the last, so we don't rely on automatic + _utils_log__WEBPACK_IMPORTED_MODULE_2__["log"].info(`Running ${watchScriptName} scripts for [${projectNames.join(', ')}].`); // Kibana should always be run the last, so we don't rely on automatic // topological batching and push it to the last one-entry batch manually. const shouldWatchKibanaProject = projectsToWatch.delete(kibanaProjectName); - const batchedProjects = Object(_utils_projects__WEBPACK_IMPORTED_MODULE_3__["topologicallyBatchProjects"])(projectsToWatch, projectGraph); + const batchedProjects = Object(_utils_projects__WEBPACK_IMPORTED_MODULE_4__["topologicallyBatchProjects"])(projectsToWatch, projectGraph); if (shouldWatchKibanaProject) { batchedProjects.push([projects.get(kibanaProjectName)]); } - await Object(_utils_parallelize__WEBPACK_IMPORTED_MODULE_2__["parallelizeBatches"])(batchedProjects, async pkg => { - const completionHint = await Object(_utils_watch__WEBPACK_IMPORTED_MODULE_4__["waitUntilWatchIsReady"])(pkg.runScriptStreaming(watchScriptName, { + await Object(_utils_parallelize__WEBPACK_IMPORTED_MODULE_3__["parallelizeBatches"])(batchedProjects, async pkg => { + const completionHint = await Object(_utils_watch__WEBPACK_IMPORTED_MODULE_5__["waitUntilWatchIsReady"])(pkg.runScriptStreaming(watchScriptName, { debug: false }).stdout // TypeScript note: As long as the proc stdio[1] is 'pipe', then stdout will not be null ); - _utils_log__WEBPACK_IMPORTED_MODULE_1__["log"].success(`[${pkg.name}] Initial build completed (${completionHint}).`); + _utils_log__WEBPACK_IMPORTED_MODULE_2__["log"].success(`[${pkg.name}] Initial build completed (${completionHint}).`); }); } }; /***/ }), -/* 513 */ +/* 514 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -59567,19 +59636,52 @@ function waitUntilWatchIsReady(stream, opts = {}) { } /***/ }), -/* 514 */ +/* 515 */ +/***/ (function(module, __webpack_exports__, __webpack_require__) { + +"use strict"; +__webpack_require__.r(__webpack_exports__); +/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "WatchBazelCommand", function() { return WatchBazelCommand; }); +/* harmony import */ var _utils_bazel__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(372); +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +const WatchBazelCommand = { + description: 'Runs a build in the Bazel built packages and keeps watching them for changes', + name: 'watch-bazel', + + async run(projects, projectGraph, { + options + }) { + const runOffline = (options === null || options === void 0 ? void 0 : options.offline) === true; // Call bazel with the target to build all available packages and run it through iBazel to watch it for changes + // + // Note: --run_output=false arg will disable the iBazel notifications about gazelle and buildozer when running it + // Can also be solved by adding a root `.bazel_fix_commands.json` but its not needed at the moment + + await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_0__["runIBazel"])(['--run_output=false', 'build', '//packages:build'], runOffline); + } + +}; + +/***/ }), +/* 516 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "runCommand", function() { return runCommand; }); -/* harmony import */ var _kbn_dev_utils_ci_stats_reporter__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(515); +/* harmony import */ var _kbn_dev_utils_ci_stats_reporter__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(517); /* harmony import */ var _kbn_dev_utils_ci_stats_reporter__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_kbn_dev_utils_ci_stats_reporter__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var _utils_errors__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(249); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(246); /* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(248); /* harmony import */ var _utils_projects_tree__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(371); -/* harmony import */ var _utils_kibana__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(558); +/* harmony import */ var _utils_kibana__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(560); function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } @@ -59697,7 +59799,7 @@ function toArray(value) { } /***/ }), -/* 515 */ +/* 517 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59716,8 +59818,8 @@ const util_1 = __webpack_require__(112); const os_1 = tslib_1.__importDefault(__webpack_require__(121)); const fs_1 = tslib_1.__importDefault(__webpack_require__(134)); const path_1 = tslib_1.__importDefault(__webpack_require__(4)); -const axios_1 = tslib_1.__importDefault(__webpack_require__(516)); -const ci_stats_config_1 = __webpack_require__(556); +const axios_1 = tslib_1.__importDefault(__webpack_require__(518)); +const ci_stats_config_1 = __webpack_require__(558); const BASE_URL = 'https://ci-stats.kibana.dev'; class CiStatsReporter { constructor(config, log) { @@ -59805,7 +59907,7 @@ class CiStatsReporter { // specify the module id in a way that will keep webpack from bundling extra code into @kbn/pm const hideFromWebpack = ['@', 'kbn/utils']; // eslint-disable-next-line @typescript-eslint/no-var-requires - const { kibanaPackageJson } = __webpack_require__(557)(hideFromWebpack.join('')); + const { kibanaPackageJson } = __webpack_require__(559)(hideFromWebpack.join('')); return kibanaPackageJson.branch; } /** @@ -59817,7 +59919,7 @@ class CiStatsReporter { // specify the module id in a way that will keep webpack from bundling extra code into @kbn/pm const hideFromWebpack = ['@', 'kbn/utils']; // eslint-disable-next-line @typescript-eslint/no-var-requires - const { REPO_ROOT } = __webpack_require__(557)(hideFromWebpack.join('')); + const { REPO_ROOT } = __webpack_require__(559)(hideFromWebpack.join('')); try { return fs_1.default.readFileSync(path_1.default.resolve(REPO_ROOT, 'data/uuid'), 'utf-8').trim(); } @@ -59880,23 +59982,23 @@ exports.CiStatsReporter = CiStatsReporter; //# sourceMappingURL=ci_stats_reporter.js.map /***/ }), -/* 516 */ +/* 518 */ /***/ (function(module, exports, __webpack_require__) { -module.exports = __webpack_require__(517); +module.exports = __webpack_require__(519); /***/ }), -/* 517 */ +/* 519 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(518); -var bind = __webpack_require__(519); -var Axios = __webpack_require__(520); -var mergeConfig = __webpack_require__(551); -var defaults = __webpack_require__(526); +var utils = __webpack_require__(520); +var bind = __webpack_require__(521); +var Axios = __webpack_require__(522); +var mergeConfig = __webpack_require__(553); +var defaults = __webpack_require__(528); /** * Create an instance of Axios @@ -59929,18 +60031,18 @@ axios.create = function create(instanceConfig) { }; // Expose Cancel & CancelToken -axios.Cancel = __webpack_require__(552); -axios.CancelToken = __webpack_require__(553); -axios.isCancel = __webpack_require__(525); +axios.Cancel = __webpack_require__(554); +axios.CancelToken = __webpack_require__(555); +axios.isCancel = __webpack_require__(527); // Expose all/spread axios.all = function all(promises) { return Promise.all(promises); }; -axios.spread = __webpack_require__(554); +axios.spread = __webpack_require__(556); // Expose isAxiosError -axios.isAxiosError = __webpack_require__(555); +axios.isAxiosError = __webpack_require__(557); module.exports = axios; @@ -59949,13 +60051,13 @@ module.exports.default = axios; /***/ }), -/* 518 */ +/* 520 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var bind = __webpack_require__(519); +var bind = __webpack_require__(521); /*global toString:true*/ @@ -60307,7 +60409,7 @@ module.exports = { /***/ }), -/* 519 */ +/* 521 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -60325,17 +60427,17 @@ module.exports = function bind(fn, thisArg) { /***/ }), -/* 520 */ +/* 522 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(518); -var buildURL = __webpack_require__(521); -var InterceptorManager = __webpack_require__(522); -var dispatchRequest = __webpack_require__(523); -var mergeConfig = __webpack_require__(551); +var utils = __webpack_require__(520); +var buildURL = __webpack_require__(523); +var InterceptorManager = __webpack_require__(524); +var dispatchRequest = __webpack_require__(525); +var mergeConfig = __webpack_require__(553); /** * Create a new instance of Axios @@ -60427,13 +60529,13 @@ module.exports = Axios; /***/ }), -/* 521 */ +/* 523 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(518); +var utils = __webpack_require__(520); function encode(val) { return encodeURIComponent(val). @@ -60504,13 +60606,13 @@ module.exports = function buildURL(url, params, paramsSerializer) { /***/ }), -/* 522 */ +/* 524 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(518); +var utils = __webpack_require__(520); function InterceptorManager() { this.handlers = []; @@ -60563,16 +60665,16 @@ module.exports = InterceptorManager; /***/ }), -/* 523 */ +/* 525 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(518); -var transformData = __webpack_require__(524); -var isCancel = __webpack_require__(525); -var defaults = __webpack_require__(526); +var utils = __webpack_require__(520); +var transformData = __webpack_require__(526); +var isCancel = __webpack_require__(527); +var defaults = __webpack_require__(528); /** * Throws a `Cancel` if cancellation has been requested. @@ -60649,13 +60751,13 @@ module.exports = function dispatchRequest(config) { /***/ }), -/* 524 */ +/* 526 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(518); +var utils = __webpack_require__(520); /** * Transform the data for a request or a response @@ -60676,7 +60778,7 @@ module.exports = function transformData(data, headers, fns) { /***/ }), -/* 525 */ +/* 527 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -60688,14 +60790,14 @@ module.exports = function isCancel(value) { /***/ }), -/* 526 */ +/* 528 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(518); -var normalizeHeaderName = __webpack_require__(527); +var utils = __webpack_require__(520); +var normalizeHeaderName = __webpack_require__(529); var DEFAULT_CONTENT_TYPE = { 'Content-Type': 'application/x-www-form-urlencoded' @@ -60711,10 +60813,10 @@ function getDefaultAdapter() { var adapter; if (typeof XMLHttpRequest !== 'undefined') { // For browsers use XHR adapter - adapter = __webpack_require__(528); + adapter = __webpack_require__(530); } else if (typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]') { // For node use HTTP adapter - adapter = __webpack_require__(538); + adapter = __webpack_require__(540); } return adapter; } @@ -60793,13 +60895,13 @@ module.exports = defaults; /***/ }), -/* 527 */ +/* 529 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(518); +var utils = __webpack_require__(520); module.exports = function normalizeHeaderName(headers, normalizedName) { utils.forEach(headers, function processHeader(value, name) { @@ -60812,20 +60914,20 @@ module.exports = function normalizeHeaderName(headers, normalizedName) { /***/ }), -/* 528 */ +/* 530 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(518); -var settle = __webpack_require__(529); -var cookies = __webpack_require__(532); -var buildURL = __webpack_require__(521); -var buildFullPath = __webpack_require__(533); -var parseHeaders = __webpack_require__(536); -var isURLSameOrigin = __webpack_require__(537); -var createError = __webpack_require__(530); +var utils = __webpack_require__(520); +var settle = __webpack_require__(531); +var cookies = __webpack_require__(534); +var buildURL = __webpack_require__(523); +var buildFullPath = __webpack_require__(535); +var parseHeaders = __webpack_require__(538); +var isURLSameOrigin = __webpack_require__(539); +var createError = __webpack_require__(532); module.exports = function xhrAdapter(config) { return new Promise(function dispatchXhrRequest(resolve, reject) { @@ -60998,13 +61100,13 @@ module.exports = function xhrAdapter(config) { /***/ }), -/* 529 */ +/* 531 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var createError = __webpack_require__(530); +var createError = __webpack_require__(532); /** * Resolve or reject a Promise based on response status. @@ -61030,13 +61132,13 @@ module.exports = function settle(resolve, reject, response) { /***/ }), -/* 530 */ +/* 532 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var enhanceError = __webpack_require__(531); +var enhanceError = __webpack_require__(533); /** * Create an Error with the specified message, config, error code, request and response. @@ -61055,7 +61157,7 @@ module.exports = function createError(message, config, code, request, response) /***/ }), -/* 531 */ +/* 533 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -61104,13 +61206,13 @@ module.exports = function enhanceError(error, config, code, request, response) { /***/ }), -/* 532 */ +/* 534 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(518); +var utils = __webpack_require__(520); module.exports = ( utils.isStandardBrowserEnv() ? @@ -61164,14 +61266,14 @@ module.exports = ( /***/ }), -/* 533 */ +/* 535 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isAbsoluteURL = __webpack_require__(534); -var combineURLs = __webpack_require__(535); +var isAbsoluteURL = __webpack_require__(536); +var combineURLs = __webpack_require__(537); /** * Creates a new URL by combining the baseURL with the requestedURL, @@ -61191,7 +61293,7 @@ module.exports = function buildFullPath(baseURL, requestedURL) { /***/ }), -/* 534 */ +/* 536 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -61212,7 +61314,7 @@ module.exports = function isAbsoluteURL(url) { /***/ }), -/* 535 */ +/* 537 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -61233,13 +61335,13 @@ module.exports = function combineURLs(baseURL, relativeURL) { /***/ }), -/* 536 */ +/* 538 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(518); +var utils = __webpack_require__(520); // Headers whose duplicates are ignored by node // c.f. https://nodejs.org/api/http.html#http_message_headers @@ -61293,13 +61395,13 @@ module.exports = function parseHeaders(headers) { /***/ }), -/* 537 */ +/* 539 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(518); +var utils = __webpack_require__(520); module.exports = ( utils.isStandardBrowserEnv() ? @@ -61368,25 +61470,25 @@ module.exports = ( /***/ }), -/* 538 */ +/* 540 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(518); -var settle = __webpack_require__(529); -var buildFullPath = __webpack_require__(533); -var buildURL = __webpack_require__(521); -var http = __webpack_require__(539); -var https = __webpack_require__(540); -var httpFollow = __webpack_require__(541).http; -var httpsFollow = __webpack_require__(541).https; +var utils = __webpack_require__(520); +var settle = __webpack_require__(531); +var buildFullPath = __webpack_require__(535); +var buildURL = __webpack_require__(523); +var http = __webpack_require__(541); +var https = __webpack_require__(542); +var httpFollow = __webpack_require__(543).http; +var httpsFollow = __webpack_require__(543).https; var url = __webpack_require__(283); -var zlib = __webpack_require__(549); -var pkg = __webpack_require__(550); -var createError = __webpack_require__(530); -var enhanceError = __webpack_require__(531); +var zlib = __webpack_require__(551); +var pkg = __webpack_require__(552); +var createError = __webpack_require__(532); +var enhanceError = __webpack_require__(533); var isHttps = /https:?/; @@ -61678,28 +61780,28 @@ module.exports = function httpAdapter(config) { /***/ }), -/* 539 */ +/* 541 */ /***/ (function(module, exports) { module.exports = require("http"); /***/ }), -/* 540 */ +/* 542 */ /***/ (function(module, exports) { module.exports = require("https"); /***/ }), -/* 541 */ +/* 543 */ /***/ (function(module, exports, __webpack_require__) { var url = __webpack_require__(283); var URL = url.URL; -var http = __webpack_require__(539); -var https = __webpack_require__(540); +var http = __webpack_require__(541); +var https = __webpack_require__(542); var Writable = __webpack_require__(138).Writable; var assert = __webpack_require__(140); -var debug = __webpack_require__(542); +var debug = __webpack_require__(544); // Create handlers that pass events from native requests var eventHandlers = Object.create(null); @@ -62194,13 +62296,13 @@ module.exports.wrap = wrap; /***/ }), -/* 542 */ +/* 544 */ /***/ (function(module, exports, __webpack_require__) { var debug; try { /* eslint global-require: off */ - debug = __webpack_require__(543)("follow-redirects"); + debug = __webpack_require__(545)("follow-redirects"); } catch (error) { debug = function () { /* */ }; @@ -62209,7 +62311,7 @@ module.exports = debug; /***/ }), -/* 543 */ +/* 545 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -62218,14 +62320,14 @@ module.exports = debug; */ if (typeof process !== 'undefined' && process.type === 'renderer') { - module.exports = __webpack_require__(544); + module.exports = __webpack_require__(546); } else { - module.exports = __webpack_require__(547); + module.exports = __webpack_require__(549); } /***/ }), -/* 544 */ +/* 546 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -62234,7 +62336,7 @@ if (typeof process !== 'undefined' && process.type === 'renderer') { * Expose `debug()` as the module. */ -exports = module.exports = __webpack_require__(545); +exports = module.exports = __webpack_require__(547); exports.log = log; exports.formatArgs = formatArgs; exports.save = save; @@ -62416,7 +62518,7 @@ function localstorage() { /***/ }), -/* 545 */ +/* 547 */ /***/ (function(module, exports, __webpack_require__) { @@ -62432,7 +62534,7 @@ exports.coerce = coerce; exports.disable = disable; exports.enable = enable; exports.enabled = enabled; -exports.humanize = __webpack_require__(546); +exports.humanize = __webpack_require__(548); /** * The currently active debug mode names, and names to skip. @@ -62624,7 +62726,7 @@ function coerce(val) { /***/ }), -/* 546 */ +/* 548 */ /***/ (function(module, exports) { /** @@ -62782,7 +62884,7 @@ function plural(ms, n, name) { /***/ }), -/* 547 */ +/* 549 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -62798,7 +62900,7 @@ var util = __webpack_require__(112); * Expose `debug()` as the module. */ -exports = module.exports = __webpack_require__(545); +exports = module.exports = __webpack_require__(547); exports.init = init; exports.log = log; exports.formatArgs = formatArgs; @@ -62977,7 +63079,7 @@ function createWritableStdioStream (fd) { case 'PIPE': case 'TCP': - var net = __webpack_require__(548); + var net = __webpack_require__(550); stream = new net.Socket({ fd: fd, readable: false, @@ -63036,31 +63138,31 @@ exports.enable(load()); /***/ }), -/* 548 */ +/* 550 */ /***/ (function(module, exports) { module.exports = require("net"); /***/ }), -/* 549 */ +/* 551 */ /***/ (function(module, exports) { module.exports = require("zlib"); /***/ }), -/* 550 */ +/* 552 */ /***/ (function(module) { module.exports = JSON.parse("{\"name\":\"axios\",\"version\":\"0.21.1\",\"description\":\"Promise based HTTP client for the browser and node.js\",\"main\":\"index.js\",\"scripts\":{\"test\":\"grunt test && bundlesize\",\"start\":\"node ./sandbox/server.js\",\"build\":\"NODE_ENV=production grunt build\",\"preversion\":\"npm test\",\"version\":\"npm run build && grunt version && git add -A dist && git add CHANGELOG.md bower.json package.json\",\"postversion\":\"git push && git push --tags\",\"examples\":\"node ./examples/server.js\",\"coveralls\":\"cat coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js\",\"fix\":\"eslint --fix lib/**/*.js\"},\"repository\":{\"type\":\"git\",\"url\":\"https://github.com/axios/axios.git\"},\"keywords\":[\"xhr\",\"http\",\"ajax\",\"promise\",\"node\"],\"author\":\"Matt Zabriskie\",\"license\":\"MIT\",\"bugs\":{\"url\":\"https://github.com/axios/axios/issues\"},\"homepage\":\"https://github.com/axios/axios\",\"devDependencies\":{\"bundlesize\":\"^0.17.0\",\"coveralls\":\"^3.0.0\",\"es6-promise\":\"^4.2.4\",\"grunt\":\"^1.0.2\",\"grunt-banner\":\"^0.6.0\",\"grunt-cli\":\"^1.2.0\",\"grunt-contrib-clean\":\"^1.1.0\",\"grunt-contrib-watch\":\"^1.0.0\",\"grunt-eslint\":\"^20.1.0\",\"grunt-karma\":\"^2.0.0\",\"grunt-mocha-test\":\"^0.13.3\",\"grunt-ts\":\"^6.0.0-beta.19\",\"grunt-webpack\":\"^1.0.18\",\"istanbul-instrumenter-loader\":\"^1.0.0\",\"jasmine-core\":\"^2.4.1\",\"karma\":\"^1.3.0\",\"karma-chrome-launcher\":\"^2.2.0\",\"karma-coverage\":\"^1.1.1\",\"karma-firefox-launcher\":\"^1.1.0\",\"karma-jasmine\":\"^1.1.1\",\"karma-jasmine-ajax\":\"^0.1.13\",\"karma-opera-launcher\":\"^1.0.0\",\"karma-safari-launcher\":\"^1.0.0\",\"karma-sauce-launcher\":\"^1.2.0\",\"karma-sinon\":\"^1.0.5\",\"karma-sourcemap-loader\":\"^0.3.7\",\"karma-webpack\":\"^1.7.0\",\"load-grunt-tasks\":\"^3.5.2\",\"minimist\":\"^1.2.0\",\"mocha\":\"^5.2.0\",\"sinon\":\"^4.5.0\",\"typescript\":\"^2.8.1\",\"url-search-params\":\"^0.10.0\",\"webpack\":\"^1.13.1\",\"webpack-dev-server\":\"^1.14.1\"},\"browser\":{\"./lib/adapters/http.js\":\"./lib/adapters/xhr.js\"},\"jsdelivr\":\"dist/axios.min.js\",\"unpkg\":\"dist/axios.min.js\",\"typings\":\"./index.d.ts\",\"dependencies\":{\"follow-redirects\":\"^1.10.0\"},\"bundlesize\":[{\"path\":\"./dist/axios.min.js\",\"threshold\":\"5kB\"}]}"); /***/ }), -/* 551 */ +/* 553 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(518); +var utils = __webpack_require__(520); /** * Config-specific merge-function which creates a new config-object @@ -63148,7 +63250,7 @@ module.exports = function mergeConfig(config1, config2) { /***/ }), -/* 552 */ +/* 554 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63174,13 +63276,13 @@ module.exports = Cancel; /***/ }), -/* 553 */ +/* 555 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var Cancel = __webpack_require__(552); +var Cancel = __webpack_require__(554); /** * A `CancelToken` is an object that can be used to request cancellation of an operation. @@ -63238,7 +63340,7 @@ module.exports = CancelToken; /***/ }), -/* 554 */ +/* 556 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63272,7 +63374,7 @@ module.exports = function spread(callback) { /***/ }), -/* 555 */ +/* 557 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63290,7 +63392,7 @@ module.exports = function isAxiosError(payload) { /***/ }), -/* 556 */ +/* 558 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63340,7 +63442,7 @@ exports.parseConfig = parseConfig; //# sourceMappingURL=ci_stats_config.js.map /***/ }), -/* 557 */ +/* 559 */ /***/ (function(module, exports) { function webpackEmptyContext(req) { @@ -63351,10 +63453,10 @@ function webpackEmptyContext(req) { webpackEmptyContext.keys = function() { return []; }; webpackEmptyContext.resolve = webpackEmptyContext; module.exports = webpackEmptyContext; -webpackEmptyContext.id = 557; +webpackEmptyContext.id = 559; /***/ }), -/* 558 */ +/* 560 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -63364,13 +63466,13 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(134); /* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(fs__WEBPACK_IMPORTED_MODULE_1__); -/* harmony import */ var multimatch__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(559); +/* harmony import */ var multimatch__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(561); /* harmony import */ var multimatch__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(multimatch__WEBPACK_IMPORTED_MODULE_2__); /* harmony import */ var is_path_inside__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(239); /* harmony import */ var is_path_inside__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(is_path_inside__WEBPACK_IMPORTED_MODULE_3__); /* harmony import */ var _yarn_lock__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(366); /* harmony import */ var _projects__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(248); -/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(562); +/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(564); function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } @@ -63534,15 +63636,15 @@ class Kibana { } /***/ }), -/* 559 */ +/* 561 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const minimatch = __webpack_require__(150); const arrayUnion = __webpack_require__(145); -const arrayDiffer = __webpack_require__(560); -const arrify = __webpack_require__(561); +const arrayDiffer = __webpack_require__(562); +const arrify = __webpack_require__(563); module.exports = (list, patterns, options = {}) => { list = arrify(list); @@ -63566,7 +63668,7 @@ module.exports = (list, patterns, options = {}) => { /***/ }), -/* 560 */ +/* 562 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63581,7 +63683,7 @@ module.exports = arrayDiffer; /***/ }), -/* 561 */ +/* 563 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63611,7 +63713,7 @@ module.exports = arrify; /***/ }), -/* 562 */ +/* 564 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -63670,15 +63772,15 @@ function getProjectPaths({ } /***/ }), -/* 563 */ +/* 565 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); -/* harmony import */ var _build_bazel_production_projects__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(564); +/* harmony import */ var _build_bazel_production_projects__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(566); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "buildBazelProductionProjects", function() { return _build_bazel_production_projects__WEBPACK_IMPORTED_MODULE_0__["buildBazelProductionProjects"]; }); -/* harmony import */ var _build_non_bazel_production_projects__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(812); +/* harmony import */ var _build_non_bazel_production_projects__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(814); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "buildNonBazelProductionProjects", function() { return _build_non_bazel_production_projects__WEBPACK_IMPORTED_MODULE_1__["buildNonBazelProductionProjects"]; }); /* @@ -63692,19 +63794,19 @@ __webpack_require__.r(__webpack_exports__); /***/ }), -/* 564 */ +/* 566 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "buildBazelProductionProjects", function() { return buildBazelProductionProjects; }); -/* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(565); +/* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(567); /* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(cpy__WEBPACK_IMPORTED_MODULE_0__); -/* harmony import */ var globby__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(774); +/* harmony import */ var globby__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(776); /* harmony import */ var globby__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(globby__WEBPACK_IMPORTED_MODULE_1__); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(4); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_2__); -/* harmony import */ var _build_non_bazel_production_projects__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(812); +/* harmony import */ var _build_non_bazel_production_projects__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(814); /* harmony import */ var _utils_bazel__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(372); /* harmony import */ var _utils_fs__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(131); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(246); @@ -63799,7 +63901,7 @@ async function applyCorrectPermissions(project, kibanaRoot, buildRoot) { } /***/ }), -/* 565 */ +/* 567 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63807,14 +63909,14 @@ async function applyCorrectPermissions(project, kibanaRoot, buildRoot) { const EventEmitter = __webpack_require__(156); const path = __webpack_require__(4); const os = __webpack_require__(121); -const pMap = __webpack_require__(566); -const arrify = __webpack_require__(561); -const globby = __webpack_require__(569); -const hasGlob = __webpack_require__(758); -const cpFile = __webpack_require__(760); -const junk = __webpack_require__(770); -const pFilter = __webpack_require__(771); -const CpyError = __webpack_require__(773); +const pMap = __webpack_require__(568); +const arrify = __webpack_require__(563); +const globby = __webpack_require__(571); +const hasGlob = __webpack_require__(760); +const cpFile = __webpack_require__(762); +const junk = __webpack_require__(772); +const pFilter = __webpack_require__(773); +const CpyError = __webpack_require__(775); const defaultOptions = { ignoreJunk: true @@ -63965,12 +64067,12 @@ module.exports = (source, destination, { /***/ }), -/* 566 */ +/* 568 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const AggregateError = __webpack_require__(567); +const AggregateError = __webpack_require__(569); module.exports = async ( iterable, @@ -64053,12 +64155,12 @@ module.exports = async ( /***/ }), -/* 567 */ +/* 569 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const indentString = __webpack_require__(568); +const indentString = __webpack_require__(570); const cleanStack = __webpack_require__(244); const cleanInternalStack = stack => stack.replace(/\s+at .*aggregate-error\/index.js:\d+:\d+\)?/g, ''); @@ -64107,7 +64209,7 @@ module.exports = AggregateError; /***/ }), -/* 568 */ +/* 570 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64149,17 +64251,17 @@ module.exports = (string, count = 1, options) => { /***/ }), -/* 569 */ +/* 571 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(134); -const arrayUnion = __webpack_require__(570); +const arrayUnion = __webpack_require__(572); const glob = __webpack_require__(147); -const fastGlob = __webpack_require__(572); -const dirGlob = __webpack_require__(751); -const gitignore = __webpack_require__(754); +const fastGlob = __webpack_require__(574); +const dirGlob = __webpack_require__(753); +const gitignore = __webpack_require__(756); const DEFAULT_FILTER = () => false; @@ -64304,12 +64406,12 @@ module.exports.gitignore = gitignore; /***/ }), -/* 570 */ +/* 572 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var arrayUniq = __webpack_require__(571); +var arrayUniq = __webpack_require__(573); module.exports = function () { return arrayUniq([].concat.apply([], arguments)); @@ -64317,7 +64419,7 @@ module.exports = function () { /***/ }), -/* 571 */ +/* 573 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64386,10 +64488,10 @@ if ('Set' in global) { /***/ }), -/* 572 */ +/* 574 */ /***/ (function(module, exports, __webpack_require__) { -const pkg = __webpack_require__(573); +const pkg = __webpack_require__(575); module.exports = pkg.async; module.exports.default = pkg.async; @@ -64402,19 +64504,19 @@ module.exports.generateTasks = pkg.generateTasks; /***/ }), -/* 573 */ +/* 575 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var optionsManager = __webpack_require__(574); -var taskManager = __webpack_require__(575); -var reader_async_1 = __webpack_require__(722); -var reader_stream_1 = __webpack_require__(746); -var reader_sync_1 = __webpack_require__(747); -var arrayUtils = __webpack_require__(749); -var streamUtils = __webpack_require__(750); +var optionsManager = __webpack_require__(576); +var taskManager = __webpack_require__(577); +var reader_async_1 = __webpack_require__(724); +var reader_stream_1 = __webpack_require__(748); +var reader_sync_1 = __webpack_require__(749); +var arrayUtils = __webpack_require__(751); +var streamUtils = __webpack_require__(752); /** * Synchronous API. */ @@ -64480,7 +64582,7 @@ function isString(source) { /***/ }), -/* 574 */ +/* 576 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64518,13 +64620,13 @@ exports.prepare = prepare; /***/ }), -/* 575 */ +/* 577 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var patternUtils = __webpack_require__(576); +var patternUtils = __webpack_require__(578); /** * Generate tasks based on parent directory of each pattern. */ @@ -64615,16 +64717,16 @@ exports.convertPatternGroupToTask = convertPatternGroupToTask; /***/ }), -/* 576 */ +/* 578 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); var path = __webpack_require__(4); -var globParent = __webpack_require__(577); +var globParent = __webpack_require__(579); var isGlob = __webpack_require__(172); -var micromatch = __webpack_require__(580); +var micromatch = __webpack_require__(582); var GLOBSTAR = '**'; /** * Return true for static pattern. @@ -64770,15 +64872,15 @@ exports.matchAny = matchAny; /***/ }), -/* 577 */ +/* 579 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var path = __webpack_require__(4); -var isglob = __webpack_require__(578); -var pathDirname = __webpack_require__(579); +var isglob = __webpack_require__(580); +var pathDirname = __webpack_require__(581); var isWin32 = __webpack_require__(121).platform() === 'win32'; module.exports = function globParent(str) { @@ -64801,7 +64903,7 @@ module.exports = function globParent(str) { /***/ }), -/* 578 */ +/* 580 */ /***/ (function(module, exports, __webpack_require__) { /*! @@ -64832,7 +64934,7 @@ module.exports = function isGlob(str) { /***/ }), -/* 579 */ +/* 581 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64982,7 +65084,7 @@ module.exports.win32 = win32; /***/ }), -/* 580 */ +/* 582 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64993,18 +65095,18 @@ module.exports.win32 = win32; */ var util = __webpack_require__(112); -var braces = __webpack_require__(581); -var toRegex = __webpack_require__(582); -var extend = __webpack_require__(690); +var braces = __webpack_require__(583); +var toRegex = __webpack_require__(584); +var extend = __webpack_require__(692); /** * Local dependencies */ -var compilers = __webpack_require__(692); -var parsers = __webpack_require__(718); -var cache = __webpack_require__(719); -var utils = __webpack_require__(720); +var compilers = __webpack_require__(694); +var parsers = __webpack_require__(720); +var cache = __webpack_require__(721); +var utils = __webpack_require__(722); var MAX_LENGTH = 1024 * 64; /** @@ -65866,7 +65968,7 @@ module.exports = micromatch; /***/ }), -/* 581 */ +/* 583 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -65876,18 +65978,18 @@ module.exports = micromatch; * Module dependencies */ -var toRegex = __webpack_require__(582); -var unique = __webpack_require__(602); -var extend = __webpack_require__(603); +var toRegex = __webpack_require__(584); +var unique = __webpack_require__(604); +var extend = __webpack_require__(605); /** * Local dependencies */ -var compilers = __webpack_require__(605); -var parsers = __webpack_require__(618); -var Braces = __webpack_require__(623); -var utils = __webpack_require__(606); +var compilers = __webpack_require__(607); +var parsers = __webpack_require__(620); +var Braces = __webpack_require__(625); +var utils = __webpack_require__(608); var MAX_LENGTH = 1024 * 64; var cache = {}; @@ -66191,16 +66293,16 @@ module.exports = braces; /***/ }), -/* 582 */ +/* 584 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var safe = __webpack_require__(583); -var define = __webpack_require__(589); -var extend = __webpack_require__(595); -var not = __webpack_require__(599); +var safe = __webpack_require__(585); +var define = __webpack_require__(591); +var extend = __webpack_require__(597); +var not = __webpack_require__(601); var MAX_LENGTH = 1024 * 64; /** @@ -66353,10 +66455,10 @@ module.exports.makeRe = makeRe; /***/ }), -/* 583 */ +/* 585 */ /***/ (function(module, exports, __webpack_require__) { -var parse = __webpack_require__(584); +var parse = __webpack_require__(586); var types = parse.types; module.exports = function (re, opts) { @@ -66402,13 +66504,13 @@ function isRegExp (x) { /***/ }), -/* 584 */ +/* 586 */ /***/ (function(module, exports, __webpack_require__) { -var util = __webpack_require__(585); -var types = __webpack_require__(586); -var sets = __webpack_require__(587); -var positions = __webpack_require__(588); +var util = __webpack_require__(587); +var types = __webpack_require__(588); +var sets = __webpack_require__(589); +var positions = __webpack_require__(590); module.exports = function(regexpStr) { @@ -66690,11 +66792,11 @@ module.exports.types = types; /***/ }), -/* 585 */ +/* 587 */ /***/ (function(module, exports, __webpack_require__) { -var types = __webpack_require__(586); -var sets = __webpack_require__(587); +var types = __webpack_require__(588); +var sets = __webpack_require__(589); // All of these are private and only used by randexp. @@ -66807,7 +66909,7 @@ exports.error = function(regexp, msg) { /***/ }), -/* 586 */ +/* 588 */ /***/ (function(module, exports) { module.exports = { @@ -66823,10 +66925,10 @@ module.exports = { /***/ }), -/* 587 */ +/* 589 */ /***/ (function(module, exports, __webpack_require__) { -var types = __webpack_require__(586); +var types = __webpack_require__(588); var INTS = function() { return [{ type: types.RANGE , from: 48, to: 57 }]; @@ -66911,10 +67013,10 @@ exports.anyChar = function() { /***/ }), -/* 588 */ +/* 590 */ /***/ (function(module, exports, __webpack_require__) { -var types = __webpack_require__(586); +var types = __webpack_require__(588); exports.wordBoundary = function() { return { type: types.POSITION, value: 'b' }; @@ -66934,7 +67036,7 @@ exports.end = function() { /***/ }), -/* 589 */ +/* 591 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -66947,8 +67049,8 @@ exports.end = function() { -var isobject = __webpack_require__(590); -var isDescriptor = __webpack_require__(591); +var isobject = __webpack_require__(592); +var isDescriptor = __webpack_require__(593); var define = (typeof Reflect !== 'undefined' && Reflect.defineProperty) ? Reflect.defineProperty : Object.defineProperty; @@ -66979,7 +67081,7 @@ module.exports = function defineProperty(obj, key, val) { /***/ }), -/* 590 */ +/* 592 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -66998,7 +67100,7 @@ module.exports = function isObject(val) { /***/ }), -/* 591 */ +/* 593 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -67011,9 +67113,9 @@ module.exports = function isObject(val) { -var typeOf = __webpack_require__(592); -var isAccessor = __webpack_require__(593); -var isData = __webpack_require__(594); +var typeOf = __webpack_require__(594); +var isAccessor = __webpack_require__(595); +var isData = __webpack_require__(596); module.exports = function isDescriptor(obj, key) { if (typeOf(obj) !== 'object') { @@ -67027,7 +67129,7 @@ module.exports = function isDescriptor(obj, key) { /***/ }), -/* 592 */ +/* 594 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -67162,7 +67264,7 @@ function isBuffer(val) { /***/ }), -/* 593 */ +/* 595 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -67175,7 +67277,7 @@ function isBuffer(val) { -var typeOf = __webpack_require__(592); +var typeOf = __webpack_require__(594); // accessor descriptor properties var accessor = { @@ -67238,7 +67340,7 @@ module.exports = isAccessorDescriptor; /***/ }), -/* 594 */ +/* 596 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -67251,7 +67353,7 @@ module.exports = isAccessorDescriptor; -var typeOf = __webpack_require__(592); +var typeOf = __webpack_require__(594); module.exports = function isDataDescriptor(obj, prop) { // data descriptor properties @@ -67294,14 +67396,14 @@ module.exports = function isDataDescriptor(obj, prop) { /***/ }), -/* 595 */ +/* 597 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(596); -var assignSymbols = __webpack_require__(598); +var isExtendable = __webpack_require__(598); +var assignSymbols = __webpack_require__(600); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -67361,7 +67463,7 @@ function isEnum(obj, key) { /***/ }), -/* 596 */ +/* 598 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -67374,7 +67476,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(597); +var isPlainObject = __webpack_require__(599); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -67382,7 +67484,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 597 */ +/* 599 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -67395,7 +67497,7 @@ module.exports = function isExtendable(val) { -var isObject = __webpack_require__(590); +var isObject = __webpack_require__(592); function isObjectObject(o) { return isObject(o) === true @@ -67426,7 +67528,7 @@ module.exports = function isPlainObject(o) { /***/ }), -/* 598 */ +/* 600 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -67473,14 +67575,14 @@ module.exports = function(receiver, objects) { /***/ }), -/* 599 */ +/* 601 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var extend = __webpack_require__(600); -var safe = __webpack_require__(583); +var extend = __webpack_require__(602); +var safe = __webpack_require__(585); /** * The main export is a function that takes a `pattern` string and an `options` object. @@ -67552,14 +67654,14 @@ module.exports = toRegex; /***/ }), -/* 600 */ +/* 602 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(601); -var assignSymbols = __webpack_require__(598); +var isExtendable = __webpack_require__(603); +var assignSymbols = __webpack_require__(600); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -67619,7 +67721,7 @@ function isEnum(obj, key) { /***/ }), -/* 601 */ +/* 603 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -67632,7 +67734,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(597); +var isPlainObject = __webpack_require__(599); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -67640,7 +67742,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 602 */ +/* 604 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -67690,13 +67792,13 @@ module.exports.immutable = function uniqueImmutable(arr) { /***/ }), -/* 603 */ +/* 605 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(604); +var isObject = __webpack_require__(606); module.exports = function extend(o/*, objects*/) { if (!isObject(o)) { o = {}; } @@ -67730,7 +67832,7 @@ function hasOwn(obj, key) { /***/ }), -/* 604 */ +/* 606 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -67750,13 +67852,13 @@ module.exports = function isExtendable(val) { /***/ }), -/* 605 */ +/* 607 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(606); +var utils = __webpack_require__(608); module.exports = function(braces, options) { braces.compiler @@ -68039,25 +68141,25 @@ function hasQueue(node) { /***/ }), -/* 606 */ +/* 608 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var splitString = __webpack_require__(607); +var splitString = __webpack_require__(609); var utils = module.exports; /** * Module dependencies */ -utils.extend = __webpack_require__(603); -utils.flatten = __webpack_require__(610); -utils.isObject = __webpack_require__(590); -utils.fillRange = __webpack_require__(611); -utils.repeat = __webpack_require__(617); -utils.unique = __webpack_require__(602); +utils.extend = __webpack_require__(605); +utils.flatten = __webpack_require__(612); +utils.isObject = __webpack_require__(592); +utils.fillRange = __webpack_require__(613); +utils.repeat = __webpack_require__(619); +utils.unique = __webpack_require__(604); utils.define = function(obj, key, val) { Object.defineProperty(obj, key, { @@ -68389,7 +68491,7 @@ utils.escapeRegex = function(str) { /***/ }), -/* 607 */ +/* 609 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68402,7 +68504,7 @@ utils.escapeRegex = function(str) { -var extend = __webpack_require__(608); +var extend = __webpack_require__(610); module.exports = function(str, options, fn) { if (typeof str !== 'string') { @@ -68567,14 +68669,14 @@ function keepEscaping(opts, str, idx) { /***/ }), -/* 608 */ +/* 610 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(609); -var assignSymbols = __webpack_require__(598); +var isExtendable = __webpack_require__(611); +var assignSymbols = __webpack_require__(600); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -68634,7 +68736,7 @@ function isEnum(obj, key) { /***/ }), -/* 609 */ +/* 611 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68647,7 +68749,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(597); +var isPlainObject = __webpack_require__(599); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -68655,7 +68757,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 610 */ +/* 612 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68684,7 +68786,7 @@ function flat(arr, res) { /***/ }), -/* 611 */ +/* 613 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68698,10 +68800,10 @@ function flat(arr, res) { var util = __webpack_require__(112); -var isNumber = __webpack_require__(612); -var extend = __webpack_require__(603); -var repeat = __webpack_require__(615); -var toRegex = __webpack_require__(616); +var isNumber = __webpack_require__(614); +var extend = __webpack_require__(605); +var repeat = __webpack_require__(617); +var toRegex = __webpack_require__(618); /** * Return a range of numbers or letters. @@ -68899,7 +69001,7 @@ module.exports = fillRange; /***/ }), -/* 612 */ +/* 614 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68912,7 +69014,7 @@ module.exports = fillRange; -var typeOf = __webpack_require__(613); +var typeOf = __webpack_require__(615); module.exports = function isNumber(num) { var type = typeOf(num); @@ -68928,10 +69030,10 @@ module.exports = function isNumber(num) { /***/ }), -/* 613 */ +/* 615 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(614); +var isBuffer = __webpack_require__(616); var toString = Object.prototype.toString; /** @@ -69050,7 +69152,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 614 */ +/* 616 */ /***/ (function(module, exports) { /*! @@ -69077,7 +69179,7 @@ function isSlowBuffer (obj) { /***/ }), -/* 615 */ +/* 617 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69154,7 +69256,7 @@ function repeat(str, num) { /***/ }), -/* 616 */ +/* 618 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69167,8 +69269,8 @@ function repeat(str, num) { -var repeat = __webpack_require__(615); -var isNumber = __webpack_require__(612); +var repeat = __webpack_require__(617); +var isNumber = __webpack_require__(614); var cache = {}; function toRegexRange(min, max, options) { @@ -69455,7 +69557,7 @@ module.exports = toRegexRange; /***/ }), -/* 617 */ +/* 619 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69480,14 +69582,14 @@ module.exports = function repeat(ele, num) { /***/ }), -/* 618 */ +/* 620 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var Node = __webpack_require__(619); -var utils = __webpack_require__(606); +var Node = __webpack_require__(621); +var utils = __webpack_require__(608); /** * Braces parsers @@ -69847,15 +69949,15 @@ function concatNodes(pos, node, parent, options) { /***/ }), -/* 619 */ +/* 621 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(590); -var define = __webpack_require__(620); -var utils = __webpack_require__(621); +var isObject = __webpack_require__(592); +var define = __webpack_require__(622); +var utils = __webpack_require__(623); var ownNames; /** @@ -70346,7 +70448,7 @@ exports = module.exports = Node; /***/ }), -/* 620 */ +/* 622 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70359,7 +70461,7 @@ exports = module.exports = Node; -var isDescriptor = __webpack_require__(591); +var isDescriptor = __webpack_require__(593); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -70384,13 +70486,13 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 621 */ +/* 623 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var typeOf = __webpack_require__(622); +var typeOf = __webpack_require__(624); var utils = module.exports; /** @@ -71410,10 +71512,10 @@ function assert(val, message) { /***/ }), -/* 622 */ +/* 624 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(614); +var isBuffer = __webpack_require__(616); var toString = Object.prototype.toString; /** @@ -71532,17 +71634,17 @@ module.exports = function kindOf(val) { /***/ }), -/* 623 */ +/* 625 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var extend = __webpack_require__(603); -var Snapdragon = __webpack_require__(624); -var compilers = __webpack_require__(605); -var parsers = __webpack_require__(618); -var utils = __webpack_require__(606); +var extend = __webpack_require__(605); +var Snapdragon = __webpack_require__(626); +var compilers = __webpack_require__(607); +var parsers = __webpack_require__(620); +var utils = __webpack_require__(608); /** * Customize Snapdragon parser and renderer @@ -71643,17 +71745,17 @@ module.exports = Braces; /***/ }), -/* 624 */ +/* 626 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var Base = __webpack_require__(625); -var define = __webpack_require__(653); -var Compiler = __webpack_require__(664); -var Parser = __webpack_require__(687); -var utils = __webpack_require__(667); +var Base = __webpack_require__(627); +var define = __webpack_require__(655); +var Compiler = __webpack_require__(666); +var Parser = __webpack_require__(689); +var utils = __webpack_require__(669); var regexCache = {}; var cache = {}; @@ -71824,20 +71926,20 @@ module.exports.Parser = Parser; /***/ }), -/* 625 */ +/* 627 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var util = __webpack_require__(112); -var define = __webpack_require__(626); -var CacheBase = __webpack_require__(627); -var Emitter = __webpack_require__(628); -var isObject = __webpack_require__(590); -var merge = __webpack_require__(647); -var pascal = __webpack_require__(650); -var cu = __webpack_require__(651); +var define = __webpack_require__(628); +var CacheBase = __webpack_require__(629); +var Emitter = __webpack_require__(630); +var isObject = __webpack_require__(592); +var merge = __webpack_require__(649); +var pascal = __webpack_require__(652); +var cu = __webpack_require__(653); /** * Optionally define a custom `cache` namespace to use. @@ -72266,7 +72368,7 @@ module.exports.namespace = namespace; /***/ }), -/* 626 */ +/* 628 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -72279,7 +72381,7 @@ module.exports.namespace = namespace; -var isDescriptor = __webpack_require__(591); +var isDescriptor = __webpack_require__(593); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -72304,21 +72406,21 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 627 */ +/* 629 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(590); -var Emitter = __webpack_require__(628); -var visit = __webpack_require__(629); -var toPath = __webpack_require__(632); -var union = __webpack_require__(634); -var del = __webpack_require__(638); -var get = __webpack_require__(636); -var has = __webpack_require__(643); -var set = __webpack_require__(646); +var isObject = __webpack_require__(592); +var Emitter = __webpack_require__(630); +var visit = __webpack_require__(631); +var toPath = __webpack_require__(634); +var union = __webpack_require__(636); +var del = __webpack_require__(640); +var get = __webpack_require__(638); +var has = __webpack_require__(645); +var set = __webpack_require__(648); /** * Create a `Cache` constructor that when instantiated will @@ -72572,7 +72674,7 @@ module.exports.namespace = namespace; /***/ }), -/* 628 */ +/* 630 */ /***/ (function(module, exports, __webpack_require__) { @@ -72741,7 +72843,7 @@ Emitter.prototype.hasListeners = function(event){ /***/ }), -/* 629 */ +/* 631 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -72754,8 +72856,8 @@ Emitter.prototype.hasListeners = function(event){ -var visit = __webpack_require__(630); -var mapVisit = __webpack_require__(631); +var visit = __webpack_require__(632); +var mapVisit = __webpack_require__(633); module.exports = function(collection, method, val) { var result; @@ -72778,7 +72880,7 @@ module.exports = function(collection, method, val) { /***/ }), -/* 630 */ +/* 632 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -72791,7 +72893,7 @@ module.exports = function(collection, method, val) { -var isObject = __webpack_require__(590); +var isObject = __webpack_require__(592); module.exports = function visit(thisArg, method, target, val) { if (!isObject(thisArg) && typeof thisArg !== 'function') { @@ -72818,14 +72920,14 @@ module.exports = function visit(thisArg, method, target, val) { /***/ }), -/* 631 */ +/* 633 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var util = __webpack_require__(112); -var visit = __webpack_require__(630); +var visit = __webpack_require__(632); /** * Map `visit` over an array of objects. @@ -72862,7 +72964,7 @@ function isObject(val) { /***/ }), -/* 632 */ +/* 634 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -72875,7 +72977,7 @@ function isObject(val) { -var typeOf = __webpack_require__(633); +var typeOf = __webpack_require__(635); module.exports = function toPath(args) { if (typeOf(args) !== 'arguments') { @@ -72902,10 +73004,10 @@ function filter(arr) { /***/ }), -/* 633 */ +/* 635 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(614); +var isBuffer = __webpack_require__(616); var toString = Object.prototype.toString; /** @@ -73024,16 +73126,16 @@ module.exports = function kindOf(val) { /***/ }), -/* 634 */ +/* 636 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(604); -var union = __webpack_require__(635); -var get = __webpack_require__(636); -var set = __webpack_require__(637); +var isObject = __webpack_require__(606); +var union = __webpack_require__(637); +var get = __webpack_require__(638); +var set = __webpack_require__(639); module.exports = function unionValue(obj, prop, value) { if (!isObject(obj)) { @@ -73061,7 +73163,7 @@ function arrayify(val) { /***/ }), -/* 635 */ +/* 637 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -73097,7 +73199,7 @@ module.exports = function union(init) { /***/ }), -/* 636 */ +/* 638 */ /***/ (function(module, exports) { /*! @@ -73153,7 +73255,7 @@ function toString(val) { /***/ }), -/* 637 */ +/* 639 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -73166,10 +73268,10 @@ function toString(val) { -var split = __webpack_require__(607); -var extend = __webpack_require__(603); -var isPlainObject = __webpack_require__(597); -var isObject = __webpack_require__(604); +var split = __webpack_require__(609); +var extend = __webpack_require__(605); +var isPlainObject = __webpack_require__(599); +var isObject = __webpack_require__(606); module.exports = function(obj, prop, val) { if (!isObject(obj)) { @@ -73215,7 +73317,7 @@ function isValidKey(key) { /***/ }), -/* 638 */ +/* 640 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -73228,8 +73330,8 @@ function isValidKey(key) { -var isObject = __webpack_require__(590); -var has = __webpack_require__(639); +var isObject = __webpack_require__(592); +var has = __webpack_require__(641); module.exports = function unset(obj, prop) { if (!isObject(obj)) { @@ -73254,7 +73356,7 @@ module.exports = function unset(obj, prop) { /***/ }), -/* 639 */ +/* 641 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -73267,9 +73369,9 @@ module.exports = function unset(obj, prop) { -var isObject = __webpack_require__(640); -var hasValues = __webpack_require__(642); -var get = __webpack_require__(636); +var isObject = __webpack_require__(642); +var hasValues = __webpack_require__(644); +var get = __webpack_require__(638); module.exports = function(obj, prop, noZero) { if (isObject(obj)) { @@ -73280,7 +73382,7 @@ module.exports = function(obj, prop, noZero) { /***/ }), -/* 640 */ +/* 642 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -73293,7 +73395,7 @@ module.exports = function(obj, prop, noZero) { -var isArray = __webpack_require__(641); +var isArray = __webpack_require__(643); module.exports = function isObject(val) { return val != null && typeof val === 'object' && isArray(val) === false; @@ -73301,7 +73403,7 @@ module.exports = function isObject(val) { /***/ }), -/* 641 */ +/* 643 */ /***/ (function(module, exports) { var toString = {}.toString; @@ -73312,7 +73414,7 @@ module.exports = Array.isArray || function (arr) { /***/ }), -/* 642 */ +/* 644 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -73355,7 +73457,7 @@ module.exports = function hasValue(o, noZero) { /***/ }), -/* 643 */ +/* 645 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -73368,9 +73470,9 @@ module.exports = function hasValue(o, noZero) { -var isObject = __webpack_require__(590); -var hasValues = __webpack_require__(644); -var get = __webpack_require__(636); +var isObject = __webpack_require__(592); +var hasValues = __webpack_require__(646); +var get = __webpack_require__(638); module.exports = function(val, prop) { return hasValues(isObject(val) && prop ? get(val, prop) : val); @@ -73378,7 +73480,7 @@ module.exports = function(val, prop) { /***/ }), -/* 644 */ +/* 646 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -73391,8 +73493,8 @@ module.exports = function(val, prop) { -var typeOf = __webpack_require__(645); -var isNumber = __webpack_require__(612); +var typeOf = __webpack_require__(647); +var isNumber = __webpack_require__(614); module.exports = function hasValue(val) { // is-number checks for NaN and other edge cases @@ -73445,10 +73547,10 @@ module.exports = function hasValue(val) { /***/ }), -/* 645 */ +/* 647 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(614); +var isBuffer = __webpack_require__(616); var toString = Object.prototype.toString; /** @@ -73570,7 +73672,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 646 */ +/* 648 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -73583,10 +73685,10 @@ module.exports = function kindOf(val) { -var split = __webpack_require__(607); -var extend = __webpack_require__(603); -var isPlainObject = __webpack_require__(597); -var isObject = __webpack_require__(604); +var split = __webpack_require__(609); +var extend = __webpack_require__(605); +var isPlainObject = __webpack_require__(599); +var isObject = __webpack_require__(606); module.exports = function(obj, prop, val) { if (!isObject(obj)) { @@ -73632,14 +73734,14 @@ function isValidKey(key) { /***/ }), -/* 647 */ +/* 649 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(648); -var forIn = __webpack_require__(649); +var isExtendable = __webpack_require__(650); +var forIn = __webpack_require__(651); function mixinDeep(target, objects) { var len = arguments.length, i = 0; @@ -73703,7 +73805,7 @@ module.exports = mixinDeep; /***/ }), -/* 648 */ +/* 650 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -73716,7 +73818,7 @@ module.exports = mixinDeep; -var isPlainObject = __webpack_require__(597); +var isPlainObject = __webpack_require__(599); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -73724,7 +73826,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 649 */ +/* 651 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -73747,7 +73849,7 @@ module.exports = function forIn(obj, fn, thisArg) { /***/ }), -/* 650 */ +/* 652 */ /***/ (function(module, exports) { /*! @@ -73774,14 +73876,14 @@ module.exports = pascalcase; /***/ }), -/* 651 */ +/* 653 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var util = __webpack_require__(112); -var utils = __webpack_require__(652); +var utils = __webpack_require__(654); /** * Expose class utils @@ -74146,7 +74248,7 @@ cu.bubble = function(Parent, events) { /***/ }), -/* 652 */ +/* 654 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -74160,10 +74262,10 @@ var utils = {}; * Lazily required module dependencies */ -utils.union = __webpack_require__(635); -utils.define = __webpack_require__(653); -utils.isObj = __webpack_require__(590); -utils.staticExtend = __webpack_require__(660); +utils.union = __webpack_require__(637); +utils.define = __webpack_require__(655); +utils.isObj = __webpack_require__(592); +utils.staticExtend = __webpack_require__(662); /** @@ -74174,7 +74276,7 @@ module.exports = utils; /***/ }), -/* 653 */ +/* 655 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -74187,7 +74289,7 @@ module.exports = utils; -var isDescriptor = __webpack_require__(654); +var isDescriptor = __webpack_require__(656); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -74212,7 +74314,7 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 654 */ +/* 656 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -74225,9 +74327,9 @@ module.exports = function defineProperty(obj, prop, val) { -var typeOf = __webpack_require__(655); -var isAccessor = __webpack_require__(656); -var isData = __webpack_require__(658); +var typeOf = __webpack_require__(657); +var isAccessor = __webpack_require__(658); +var isData = __webpack_require__(660); module.exports = function isDescriptor(obj, key) { if (typeOf(obj) !== 'object') { @@ -74241,7 +74343,7 @@ module.exports = function isDescriptor(obj, key) { /***/ }), -/* 655 */ +/* 657 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -74394,7 +74496,7 @@ function isBuffer(val) { /***/ }), -/* 656 */ +/* 658 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -74407,7 +74509,7 @@ function isBuffer(val) { -var typeOf = __webpack_require__(657); +var typeOf = __webpack_require__(659); // accessor descriptor properties var accessor = { @@ -74470,10 +74572,10 @@ module.exports = isAccessorDescriptor; /***/ }), -/* 657 */ +/* 659 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(614); +var isBuffer = __webpack_require__(616); var toString = Object.prototype.toString; /** @@ -74592,7 +74694,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 658 */ +/* 660 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -74605,7 +74707,7 @@ module.exports = function kindOf(val) { -var typeOf = __webpack_require__(659); +var typeOf = __webpack_require__(661); // data descriptor properties var data = { @@ -74654,10 +74756,10 @@ module.exports = isDataDescriptor; /***/ }), -/* 659 */ +/* 661 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(614); +var isBuffer = __webpack_require__(616); var toString = Object.prototype.toString; /** @@ -74776,7 +74878,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 660 */ +/* 662 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -74789,8 +74891,8 @@ module.exports = function kindOf(val) { -var copy = __webpack_require__(661); -var define = __webpack_require__(653); +var copy = __webpack_require__(663); +var define = __webpack_require__(655); var util = __webpack_require__(112); /** @@ -74873,15 +74975,15 @@ module.exports = extend; /***/ }), -/* 661 */ +/* 663 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var typeOf = __webpack_require__(662); -var copyDescriptor = __webpack_require__(663); -var define = __webpack_require__(653); +var typeOf = __webpack_require__(664); +var copyDescriptor = __webpack_require__(665); +var define = __webpack_require__(655); /** * Copy static properties, prototype properties, and descriptors from one object to another. @@ -75054,10 +75156,10 @@ module.exports.has = has; /***/ }), -/* 662 */ +/* 664 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(614); +var isBuffer = __webpack_require__(616); var toString = Object.prototype.toString; /** @@ -75176,7 +75278,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 663 */ +/* 665 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -75264,16 +75366,16 @@ function isObject(val) { /***/ }), -/* 664 */ +/* 666 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var use = __webpack_require__(665); -var define = __webpack_require__(653); -var debug = __webpack_require__(543)('snapdragon:compiler'); -var utils = __webpack_require__(667); +var use = __webpack_require__(667); +var define = __webpack_require__(655); +var debug = __webpack_require__(545)('snapdragon:compiler'); +var utils = __webpack_require__(669); /** * Create a new `Compiler` with the given `options`. @@ -75427,7 +75529,7 @@ Compiler.prototype = { // source map support if (opts.sourcemap) { - var sourcemaps = __webpack_require__(686); + var sourcemaps = __webpack_require__(688); sourcemaps(this); this.mapVisit(this.ast.nodes); this.applySourceMaps(); @@ -75448,7 +75550,7 @@ module.exports = Compiler; /***/ }), -/* 665 */ +/* 667 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -75461,7 +75563,7 @@ module.exports = Compiler; -var utils = __webpack_require__(666); +var utils = __webpack_require__(668); module.exports = function base(app, opts) { if (!utils.isObject(app) && typeof app !== 'function') { @@ -75576,7 +75678,7 @@ module.exports = function base(app, opts) { /***/ }), -/* 666 */ +/* 668 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -75590,8 +75692,8 @@ var utils = {}; * Lazily required module dependencies */ -utils.define = __webpack_require__(653); -utils.isObject = __webpack_require__(590); +utils.define = __webpack_require__(655); +utils.isObject = __webpack_require__(592); utils.isString = function(val) { @@ -75606,7 +75708,7 @@ module.exports = utils; /***/ }), -/* 667 */ +/* 669 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -75616,9 +75718,9 @@ module.exports = utils; * Module dependencies */ -exports.extend = __webpack_require__(603); -exports.SourceMap = __webpack_require__(668); -exports.sourceMapResolve = __webpack_require__(679); +exports.extend = __webpack_require__(605); +exports.SourceMap = __webpack_require__(670); +exports.sourceMapResolve = __webpack_require__(681); /** * Convert backslash in the given string to forward slashes @@ -75661,7 +75763,7 @@ exports.last = function(arr, n) { /***/ }), -/* 668 */ +/* 670 */ /***/ (function(module, exports, __webpack_require__) { /* @@ -75669,13 +75771,13 @@ exports.last = function(arr, n) { * Licensed under the New BSD license. See LICENSE.txt or: * http://opensource.org/licenses/BSD-3-Clause */ -exports.SourceMapGenerator = __webpack_require__(669).SourceMapGenerator; -exports.SourceMapConsumer = __webpack_require__(675).SourceMapConsumer; -exports.SourceNode = __webpack_require__(678).SourceNode; +exports.SourceMapGenerator = __webpack_require__(671).SourceMapGenerator; +exports.SourceMapConsumer = __webpack_require__(677).SourceMapConsumer; +exports.SourceNode = __webpack_require__(680).SourceNode; /***/ }), -/* 669 */ +/* 671 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -75685,10 +75787,10 @@ exports.SourceNode = __webpack_require__(678).SourceNode; * http://opensource.org/licenses/BSD-3-Clause */ -var base64VLQ = __webpack_require__(670); -var util = __webpack_require__(672); -var ArraySet = __webpack_require__(673).ArraySet; -var MappingList = __webpack_require__(674).MappingList; +var base64VLQ = __webpack_require__(672); +var util = __webpack_require__(674); +var ArraySet = __webpack_require__(675).ArraySet; +var MappingList = __webpack_require__(676).MappingList; /** * An instance of the SourceMapGenerator represents a source map which is @@ -76097,7 +76199,7 @@ exports.SourceMapGenerator = SourceMapGenerator; /***/ }), -/* 670 */ +/* 672 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -76137,7 +76239,7 @@ exports.SourceMapGenerator = SourceMapGenerator; * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -var base64 = __webpack_require__(671); +var base64 = __webpack_require__(673); // A single base 64 digit can contain 6 bits of data. For the base 64 variable // length quantities we use in the source map spec, the first bit is the sign, @@ -76243,7 +76345,7 @@ exports.decode = function base64VLQ_decode(aStr, aIndex, aOutParam) { /***/ }), -/* 671 */ +/* 673 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -76316,7 +76418,7 @@ exports.decode = function (charCode) { /***/ }), -/* 672 */ +/* 674 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -76739,7 +76841,7 @@ exports.compareByGeneratedPositionsInflated = compareByGeneratedPositionsInflate /***/ }), -/* 673 */ +/* 675 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -76749,7 +76851,7 @@ exports.compareByGeneratedPositionsInflated = compareByGeneratedPositionsInflate * http://opensource.org/licenses/BSD-3-Clause */ -var util = __webpack_require__(672); +var util = __webpack_require__(674); var has = Object.prototype.hasOwnProperty; var hasNativeMap = typeof Map !== "undefined"; @@ -76866,7 +76968,7 @@ exports.ArraySet = ArraySet; /***/ }), -/* 674 */ +/* 676 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -76876,7 +76978,7 @@ exports.ArraySet = ArraySet; * http://opensource.org/licenses/BSD-3-Clause */ -var util = __webpack_require__(672); +var util = __webpack_require__(674); /** * Determine whether mappingB is after mappingA with respect to generated @@ -76951,7 +77053,7 @@ exports.MappingList = MappingList; /***/ }), -/* 675 */ +/* 677 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -76961,11 +77063,11 @@ exports.MappingList = MappingList; * http://opensource.org/licenses/BSD-3-Clause */ -var util = __webpack_require__(672); -var binarySearch = __webpack_require__(676); -var ArraySet = __webpack_require__(673).ArraySet; -var base64VLQ = __webpack_require__(670); -var quickSort = __webpack_require__(677).quickSort; +var util = __webpack_require__(674); +var binarySearch = __webpack_require__(678); +var ArraySet = __webpack_require__(675).ArraySet; +var base64VLQ = __webpack_require__(672); +var quickSort = __webpack_require__(679).quickSort; function SourceMapConsumer(aSourceMap) { var sourceMap = aSourceMap; @@ -78039,7 +78141,7 @@ exports.IndexedSourceMapConsumer = IndexedSourceMapConsumer; /***/ }), -/* 676 */ +/* 678 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -78156,7 +78258,7 @@ exports.search = function search(aNeedle, aHaystack, aCompare, aBias) { /***/ }), -/* 677 */ +/* 679 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -78276,7 +78378,7 @@ exports.quickSort = function (ary, comparator) { /***/ }), -/* 678 */ +/* 680 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -78286,8 +78388,8 @@ exports.quickSort = function (ary, comparator) { * http://opensource.org/licenses/BSD-3-Clause */ -var SourceMapGenerator = __webpack_require__(669).SourceMapGenerator; -var util = __webpack_require__(672); +var SourceMapGenerator = __webpack_require__(671).SourceMapGenerator; +var util = __webpack_require__(674); // Matches a Windows-style `\r\n` newline or a `\n` newline used by all other // operating systems these days (capturing the result). @@ -78695,17 +78797,17 @@ exports.SourceNode = SourceNode; /***/ }), -/* 679 */ +/* 681 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2014, 2015, 2016, 2017 Simon Lydell // X11 (“MIT”) Licensed. (See LICENSE.) -var sourceMappingURL = __webpack_require__(680) -var resolveUrl = __webpack_require__(681) -var decodeUriComponent = __webpack_require__(682) -var urix = __webpack_require__(684) -var atob = __webpack_require__(685) +var sourceMappingURL = __webpack_require__(682) +var resolveUrl = __webpack_require__(683) +var decodeUriComponent = __webpack_require__(684) +var urix = __webpack_require__(686) +var atob = __webpack_require__(687) @@ -79003,7 +79105,7 @@ module.exports = { /***/ }), -/* 680 */ +/* 682 */ /***/ (function(module, exports, __webpack_require__) { var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_RESULT__;// Copyright 2014 Simon Lydell @@ -79066,7 +79168,7 @@ void (function(root, factory) { /***/ }), -/* 681 */ +/* 683 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2014 Simon Lydell @@ -79084,13 +79186,13 @@ module.exports = resolveUrl /***/ }), -/* 682 */ +/* 684 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2017 Simon Lydell // X11 (“MIT”) Licensed. (See LICENSE.) -var decodeUriComponent = __webpack_require__(683) +var decodeUriComponent = __webpack_require__(685) function customDecodeUriComponent(string) { // `decodeUriComponent` turns `+` into ` `, but that's not wanted. @@ -79101,7 +79203,7 @@ module.exports = customDecodeUriComponent /***/ }), -/* 683 */ +/* 685 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79202,7 +79304,7 @@ module.exports = function (encodedURI) { /***/ }), -/* 684 */ +/* 686 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2014 Simon Lydell @@ -79225,7 +79327,7 @@ module.exports = urix /***/ }), -/* 685 */ +/* 687 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79239,7 +79341,7 @@ module.exports = atob.atob = atob; /***/ }), -/* 686 */ +/* 688 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79247,8 +79349,8 @@ module.exports = atob.atob = atob; var fs = __webpack_require__(134); var path = __webpack_require__(4); -var define = __webpack_require__(653); -var utils = __webpack_require__(667); +var define = __webpack_require__(655); +var utils = __webpack_require__(669); /** * Expose `mixin()`. @@ -79391,19 +79493,19 @@ exports.comment = function(node) { /***/ }), -/* 687 */ +/* 689 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var use = __webpack_require__(665); +var use = __webpack_require__(667); var util = __webpack_require__(112); -var Cache = __webpack_require__(688); -var define = __webpack_require__(653); -var debug = __webpack_require__(543)('snapdragon:parser'); -var Position = __webpack_require__(689); -var utils = __webpack_require__(667); +var Cache = __webpack_require__(690); +var define = __webpack_require__(655); +var debug = __webpack_require__(545)('snapdragon:parser'); +var Position = __webpack_require__(691); +var utils = __webpack_require__(669); /** * Create a new `Parser` with the given `input` and `options`. @@ -79931,7 +80033,7 @@ module.exports = Parser; /***/ }), -/* 688 */ +/* 690 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -80038,13 +80140,13 @@ MapCache.prototype.del = function mapDelete(key) { /***/ }), -/* 689 */ +/* 691 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var define = __webpack_require__(653); +var define = __webpack_require__(655); /** * Store position for a node @@ -80059,14 +80161,14 @@ module.exports = function Position(start, parser) { /***/ }), -/* 690 */ +/* 692 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(691); -var assignSymbols = __webpack_require__(598); +var isExtendable = __webpack_require__(693); +var assignSymbols = __webpack_require__(600); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -80126,7 +80228,7 @@ function isEnum(obj, key) { /***/ }), -/* 691 */ +/* 693 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -80139,7 +80241,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(597); +var isPlainObject = __webpack_require__(599); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -80147,14 +80249,14 @@ module.exports = function isExtendable(val) { /***/ }), -/* 692 */ +/* 694 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var nanomatch = __webpack_require__(693); -var extglob = __webpack_require__(707); +var nanomatch = __webpack_require__(695); +var extglob = __webpack_require__(709); module.exports = function(snapdragon) { var compilers = snapdragon.compiler.compilers; @@ -80231,7 +80333,7 @@ function escapeExtglobs(compiler) { /***/ }), -/* 693 */ +/* 695 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -80242,17 +80344,17 @@ function escapeExtglobs(compiler) { */ var util = __webpack_require__(112); -var toRegex = __webpack_require__(582); -var extend = __webpack_require__(694); +var toRegex = __webpack_require__(584); +var extend = __webpack_require__(696); /** * Local dependencies */ -var compilers = __webpack_require__(696); -var parsers = __webpack_require__(697); -var cache = __webpack_require__(700); -var utils = __webpack_require__(702); +var compilers = __webpack_require__(698); +var parsers = __webpack_require__(699); +var cache = __webpack_require__(702); +var utils = __webpack_require__(704); var MAX_LENGTH = 1024 * 64; /** @@ -81076,14 +81178,14 @@ module.exports = nanomatch; /***/ }), -/* 694 */ +/* 696 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(695); -var assignSymbols = __webpack_require__(598); +var isExtendable = __webpack_require__(697); +var assignSymbols = __webpack_require__(600); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -81143,7 +81245,7 @@ function isEnum(obj, key) { /***/ }), -/* 695 */ +/* 697 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81156,7 +81258,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(597); +var isPlainObject = __webpack_require__(599); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -81164,7 +81266,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 696 */ +/* 698 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81510,15 +81612,15 @@ module.exports = function(nanomatch, options) { /***/ }), -/* 697 */ +/* 699 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var regexNot = __webpack_require__(599); -var toRegex = __webpack_require__(582); -var isOdd = __webpack_require__(698); +var regexNot = __webpack_require__(601); +var toRegex = __webpack_require__(584); +var isOdd = __webpack_require__(700); /** * Characters to use in negation regex (we want to "not" match @@ -81904,7 +82006,7 @@ module.exports.not = NOT_REGEX; /***/ }), -/* 698 */ +/* 700 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81917,7 +82019,7 @@ module.exports.not = NOT_REGEX; -var isNumber = __webpack_require__(699); +var isNumber = __webpack_require__(701); module.exports = function isOdd(i) { if (!isNumber(i)) { @@ -81931,7 +82033,7 @@ module.exports = function isOdd(i) { /***/ }), -/* 699 */ +/* 701 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81959,14 +82061,14 @@ module.exports = function isNumber(num) { /***/ }), -/* 700 */ +/* 702 */ /***/ (function(module, exports, __webpack_require__) { -module.exports = new (__webpack_require__(701))(); +module.exports = new (__webpack_require__(703))(); /***/ }), -/* 701 */ +/* 703 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81979,7 +82081,7 @@ module.exports = new (__webpack_require__(701))(); -var MapCache = __webpack_require__(688); +var MapCache = __webpack_require__(690); /** * Create a new `FragmentCache` with an optional object to use for `caches`. @@ -82101,7 +82203,7 @@ exports = module.exports = FragmentCache; /***/ }), -/* 702 */ +/* 704 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82114,14 +82216,14 @@ var path = __webpack_require__(4); * Module dependencies */ -var isWindows = __webpack_require__(703)(); -var Snapdragon = __webpack_require__(624); -utils.define = __webpack_require__(704); -utils.diff = __webpack_require__(705); -utils.extend = __webpack_require__(694); -utils.pick = __webpack_require__(706); -utils.typeOf = __webpack_require__(592); -utils.unique = __webpack_require__(602); +var isWindows = __webpack_require__(705)(); +var Snapdragon = __webpack_require__(626); +utils.define = __webpack_require__(706); +utils.diff = __webpack_require__(707); +utils.extend = __webpack_require__(696); +utils.pick = __webpack_require__(708); +utils.typeOf = __webpack_require__(594); +utils.unique = __webpack_require__(604); /** * Returns true if the given value is effectively an empty string @@ -82487,7 +82589,7 @@ utils.unixify = function(options) { /***/ }), -/* 703 */ +/* 705 */ /***/ (function(module, exports, __webpack_require__) { var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_DEFINE_RESULT__;/*! @@ -82515,7 +82617,7 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_ /***/ }), -/* 704 */ +/* 706 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82528,8 +82630,8 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_ -var isobject = __webpack_require__(590); -var isDescriptor = __webpack_require__(591); +var isobject = __webpack_require__(592); +var isDescriptor = __webpack_require__(593); var define = (typeof Reflect !== 'undefined' && Reflect.defineProperty) ? Reflect.defineProperty : Object.defineProperty; @@ -82560,7 +82662,7 @@ module.exports = function defineProperty(obj, key, val) { /***/ }), -/* 705 */ +/* 707 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82614,7 +82716,7 @@ function diffArray(one, two) { /***/ }), -/* 706 */ +/* 708 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82627,7 +82729,7 @@ function diffArray(one, two) { -var isObject = __webpack_require__(590); +var isObject = __webpack_require__(592); module.exports = function pick(obj, keys) { if (!isObject(obj) && typeof obj !== 'function') { @@ -82656,7 +82758,7 @@ module.exports = function pick(obj, keys) { /***/ }), -/* 707 */ +/* 709 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82666,18 +82768,18 @@ module.exports = function pick(obj, keys) { * Module dependencies */ -var extend = __webpack_require__(603); -var unique = __webpack_require__(602); -var toRegex = __webpack_require__(582); +var extend = __webpack_require__(605); +var unique = __webpack_require__(604); +var toRegex = __webpack_require__(584); /** * Local dependencies */ -var compilers = __webpack_require__(708); -var parsers = __webpack_require__(714); -var Extglob = __webpack_require__(717); -var utils = __webpack_require__(716); +var compilers = __webpack_require__(710); +var parsers = __webpack_require__(716); +var Extglob = __webpack_require__(719); +var utils = __webpack_require__(718); var MAX_LENGTH = 1024 * 64; /** @@ -82994,13 +83096,13 @@ module.exports = extglob; /***/ }), -/* 708 */ +/* 710 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var brackets = __webpack_require__(709); +var brackets = __webpack_require__(711); /** * Extglob compilers @@ -83170,7 +83272,7 @@ module.exports = function(extglob) { /***/ }), -/* 709 */ +/* 711 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83180,17 +83282,17 @@ module.exports = function(extglob) { * Local dependencies */ -var compilers = __webpack_require__(710); -var parsers = __webpack_require__(712); +var compilers = __webpack_require__(712); +var parsers = __webpack_require__(714); /** * Module dependencies */ -var debug = __webpack_require__(543)('expand-brackets'); -var extend = __webpack_require__(603); -var Snapdragon = __webpack_require__(624); -var toRegex = __webpack_require__(582); +var debug = __webpack_require__(545)('expand-brackets'); +var extend = __webpack_require__(605); +var Snapdragon = __webpack_require__(626); +var toRegex = __webpack_require__(584); /** * Parses the given POSIX character class `pattern` and returns a @@ -83388,13 +83490,13 @@ module.exports = brackets; /***/ }), -/* 710 */ +/* 712 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var posix = __webpack_require__(711); +var posix = __webpack_require__(713); module.exports = function(brackets) { brackets.compiler @@ -83482,7 +83584,7 @@ module.exports = function(brackets) { /***/ }), -/* 711 */ +/* 713 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83511,14 +83613,14 @@ module.exports = { /***/ }), -/* 712 */ +/* 714 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(713); -var define = __webpack_require__(653); +var utils = __webpack_require__(715); +var define = __webpack_require__(655); /** * Text regex @@ -83737,14 +83839,14 @@ module.exports.TEXT_REGEX = TEXT_REGEX; /***/ }), -/* 713 */ +/* 715 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var toRegex = __webpack_require__(582); -var regexNot = __webpack_require__(599); +var toRegex = __webpack_require__(584); +var regexNot = __webpack_require__(601); var cached; /** @@ -83778,15 +83880,15 @@ exports.createRegex = function(pattern, include) { /***/ }), -/* 714 */ +/* 716 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var brackets = __webpack_require__(709); -var define = __webpack_require__(715); -var utils = __webpack_require__(716); +var brackets = __webpack_require__(711); +var define = __webpack_require__(717); +var utils = __webpack_require__(718); /** * Characters to use in text regex (we want to "not" match @@ -83941,7 +84043,7 @@ module.exports = parsers; /***/ }), -/* 715 */ +/* 717 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83954,7 +84056,7 @@ module.exports = parsers; -var isDescriptor = __webpack_require__(591); +var isDescriptor = __webpack_require__(593); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -83979,14 +84081,14 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 716 */ +/* 718 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var regex = __webpack_require__(599); -var Cache = __webpack_require__(701); +var regex = __webpack_require__(601); +var Cache = __webpack_require__(703); /** * Utils @@ -84055,7 +84157,7 @@ utils.createRegex = function(str) { /***/ }), -/* 717 */ +/* 719 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84065,16 +84167,16 @@ utils.createRegex = function(str) { * Module dependencies */ -var Snapdragon = __webpack_require__(624); -var define = __webpack_require__(715); -var extend = __webpack_require__(603); +var Snapdragon = __webpack_require__(626); +var define = __webpack_require__(717); +var extend = __webpack_require__(605); /** * Local dependencies */ -var compilers = __webpack_require__(708); -var parsers = __webpack_require__(714); +var compilers = __webpack_require__(710); +var parsers = __webpack_require__(716); /** * Customize Snapdragon parser and renderer @@ -84140,16 +84242,16 @@ module.exports = Extglob; /***/ }), -/* 718 */ +/* 720 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var extglob = __webpack_require__(707); -var nanomatch = __webpack_require__(693); -var regexNot = __webpack_require__(599); -var toRegex = __webpack_require__(582); +var extglob = __webpack_require__(709); +var nanomatch = __webpack_require__(695); +var regexNot = __webpack_require__(601); +var toRegex = __webpack_require__(584); var not; /** @@ -84230,14 +84332,14 @@ function textRegex(pattern) { /***/ }), -/* 719 */ +/* 721 */ /***/ (function(module, exports, __webpack_require__) { -module.exports = new (__webpack_require__(701))(); +module.exports = new (__webpack_require__(703))(); /***/ }), -/* 720 */ +/* 722 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84250,13 +84352,13 @@ var path = __webpack_require__(4); * Module dependencies */ -var Snapdragon = __webpack_require__(624); -utils.define = __webpack_require__(721); -utils.diff = __webpack_require__(705); -utils.extend = __webpack_require__(690); -utils.pick = __webpack_require__(706); -utils.typeOf = __webpack_require__(592); -utils.unique = __webpack_require__(602); +var Snapdragon = __webpack_require__(626); +utils.define = __webpack_require__(723); +utils.diff = __webpack_require__(707); +utils.extend = __webpack_require__(692); +utils.pick = __webpack_require__(708); +utils.typeOf = __webpack_require__(594); +utils.unique = __webpack_require__(604); /** * Returns true if the platform is windows, or `path.sep` is `\\`. @@ -84553,7 +84655,7 @@ utils.unixify = function(options) { /***/ }), -/* 721 */ +/* 723 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84566,8 +84668,8 @@ utils.unixify = function(options) { -var isobject = __webpack_require__(590); -var isDescriptor = __webpack_require__(591); +var isobject = __webpack_require__(592); +var isDescriptor = __webpack_require__(593); var define = (typeof Reflect !== 'undefined' && Reflect.defineProperty) ? Reflect.defineProperty : Object.defineProperty; @@ -84598,7 +84700,7 @@ module.exports = function defineProperty(obj, key, val) { /***/ }), -/* 722 */ +/* 724 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84617,9 +84719,9 @@ var __extends = (this && this.__extends) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -var readdir = __webpack_require__(723); -var reader_1 = __webpack_require__(736); -var fs_stream_1 = __webpack_require__(740); +var readdir = __webpack_require__(725); +var reader_1 = __webpack_require__(738); +var fs_stream_1 = __webpack_require__(742); var ReaderAsync = /** @class */ (function (_super) { __extends(ReaderAsync, _super); function ReaderAsync() { @@ -84680,15 +84782,15 @@ exports.default = ReaderAsync; /***/ }), -/* 723 */ +/* 725 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const readdirSync = __webpack_require__(724); -const readdirAsync = __webpack_require__(732); -const readdirStream = __webpack_require__(735); +const readdirSync = __webpack_require__(726); +const readdirAsync = __webpack_require__(734); +const readdirStream = __webpack_require__(737); module.exports = exports = readdirAsyncPath; exports.readdir = exports.readdirAsync = exports.async = readdirAsyncPath; @@ -84772,7 +84874,7 @@ function readdirStreamStat (dir, options) { /***/ }), -/* 724 */ +/* 726 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84780,11 +84882,11 @@ function readdirStreamStat (dir, options) { module.exports = readdirSync; -const DirectoryReader = __webpack_require__(725); +const DirectoryReader = __webpack_require__(727); let syncFacade = { - fs: __webpack_require__(730), - forEach: __webpack_require__(731), + fs: __webpack_require__(732), + forEach: __webpack_require__(733), sync: true }; @@ -84813,7 +84915,7 @@ function readdirSync (dir, options, internalOptions) { /***/ }), -/* 725 */ +/* 727 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84822,9 +84924,9 @@ function readdirSync (dir, options, internalOptions) { const Readable = __webpack_require__(138).Readable; const EventEmitter = __webpack_require__(156).EventEmitter; const path = __webpack_require__(4); -const normalizeOptions = __webpack_require__(726); -const stat = __webpack_require__(728); -const call = __webpack_require__(729); +const normalizeOptions = __webpack_require__(728); +const stat = __webpack_require__(730); +const call = __webpack_require__(731); /** * Asynchronously reads the contents of a directory and streams the results @@ -85200,14 +85302,14 @@ module.exports = DirectoryReader; /***/ }), -/* 726 */ +/* 728 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(4); -const globToRegExp = __webpack_require__(727); +const globToRegExp = __webpack_require__(729); module.exports = normalizeOptions; @@ -85384,7 +85486,7 @@ function normalizeOptions (options, internalOptions) { /***/ }), -/* 727 */ +/* 729 */ /***/ (function(module, exports) { module.exports = function (glob, opts) { @@ -85521,13 +85623,13 @@ module.exports = function (glob, opts) { /***/ }), -/* 728 */ +/* 730 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const call = __webpack_require__(729); +const call = __webpack_require__(731); module.exports = stat; @@ -85602,7 +85704,7 @@ function symlinkStat (fs, path, lstats, callback) { /***/ }), -/* 729 */ +/* 731 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85663,14 +85765,14 @@ function callOnce (fn) { /***/ }), -/* 730 */ +/* 732 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(134); -const call = __webpack_require__(729); +const call = __webpack_require__(731); /** * A facade around {@link fs.readdirSync} that allows it to be called @@ -85734,7 +85836,7 @@ exports.lstat = function (path, callback) { /***/ }), -/* 731 */ +/* 733 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85763,7 +85865,7 @@ function syncForEach (array, iterator, done) { /***/ }), -/* 732 */ +/* 734 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85771,12 +85873,12 @@ function syncForEach (array, iterator, done) { module.exports = readdirAsync; -const maybe = __webpack_require__(733); -const DirectoryReader = __webpack_require__(725); +const maybe = __webpack_require__(735); +const DirectoryReader = __webpack_require__(727); let asyncFacade = { fs: __webpack_require__(134), - forEach: __webpack_require__(734), + forEach: __webpack_require__(736), async: true }; @@ -85818,7 +85920,7 @@ function readdirAsync (dir, options, callback, internalOptions) { /***/ }), -/* 733 */ +/* 735 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85845,7 +85947,7 @@ module.exports = function maybe (cb, promise) { /***/ }), -/* 734 */ +/* 736 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85881,7 +85983,7 @@ function asyncForEach (array, iterator, done) { /***/ }), -/* 735 */ +/* 737 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85889,11 +85991,11 @@ function asyncForEach (array, iterator, done) { module.exports = readdirStream; -const DirectoryReader = __webpack_require__(725); +const DirectoryReader = __webpack_require__(727); let streamFacade = { fs: __webpack_require__(134), - forEach: __webpack_require__(734), + forEach: __webpack_require__(736), async: true }; @@ -85913,16 +86015,16 @@ function readdirStream (dir, options, internalOptions) { /***/ }), -/* 736 */ +/* 738 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); var path = __webpack_require__(4); -var deep_1 = __webpack_require__(737); -var entry_1 = __webpack_require__(739); -var pathUtil = __webpack_require__(738); +var deep_1 = __webpack_require__(739); +var entry_1 = __webpack_require__(741); +var pathUtil = __webpack_require__(740); var Reader = /** @class */ (function () { function Reader(options) { this.options = options; @@ -85988,14 +86090,14 @@ exports.default = Reader; /***/ }), -/* 737 */ +/* 739 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var pathUtils = __webpack_require__(738); -var patternUtils = __webpack_require__(576); +var pathUtils = __webpack_require__(740); +var patternUtils = __webpack_require__(578); var DeepFilter = /** @class */ (function () { function DeepFilter(options, micromatchOptions) { this.options = options; @@ -86078,7 +86180,7 @@ exports.default = DeepFilter; /***/ }), -/* 738 */ +/* 740 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -86109,14 +86211,14 @@ exports.makeAbsolute = makeAbsolute; /***/ }), -/* 739 */ +/* 741 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var pathUtils = __webpack_require__(738); -var patternUtils = __webpack_require__(576); +var pathUtils = __webpack_require__(740); +var patternUtils = __webpack_require__(578); var EntryFilter = /** @class */ (function () { function EntryFilter(options, micromatchOptions) { this.options = options; @@ -86201,7 +86303,7 @@ exports.default = EntryFilter; /***/ }), -/* 740 */ +/* 742 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -86221,8 +86323,8 @@ var __extends = (this && this.__extends) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); var stream = __webpack_require__(138); -var fsStat = __webpack_require__(741); -var fs_1 = __webpack_require__(745); +var fsStat = __webpack_require__(743); +var fs_1 = __webpack_require__(747); var FileSystemStream = /** @class */ (function (_super) { __extends(FileSystemStream, _super); function FileSystemStream() { @@ -86272,14 +86374,14 @@ exports.default = FileSystemStream; /***/ }), -/* 741 */ +/* 743 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const optionsManager = __webpack_require__(742); -const statProvider = __webpack_require__(744); +const optionsManager = __webpack_require__(744); +const statProvider = __webpack_require__(746); /** * Asynchronous API. */ @@ -86310,13 +86412,13 @@ exports.statSync = statSync; /***/ }), -/* 742 */ +/* 744 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const fsAdapter = __webpack_require__(743); +const fsAdapter = __webpack_require__(745); function prepare(opts) { const options = Object.assign({ fs: fsAdapter.getFileSystemAdapter(opts ? opts.fs : undefined), @@ -86329,7 +86431,7 @@ exports.prepare = prepare; /***/ }), -/* 743 */ +/* 745 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -86352,7 +86454,7 @@ exports.getFileSystemAdapter = getFileSystemAdapter; /***/ }), -/* 744 */ +/* 746 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -86404,7 +86506,7 @@ exports.isFollowedSymlink = isFollowedSymlink; /***/ }), -/* 745 */ +/* 747 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -86435,7 +86537,7 @@ exports.default = FileSystem; /***/ }), -/* 746 */ +/* 748 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -86455,9 +86557,9 @@ var __extends = (this && this.__extends) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); var stream = __webpack_require__(138); -var readdir = __webpack_require__(723); -var reader_1 = __webpack_require__(736); -var fs_stream_1 = __webpack_require__(740); +var readdir = __webpack_require__(725); +var reader_1 = __webpack_require__(738); +var fs_stream_1 = __webpack_require__(742); var TransformStream = /** @class */ (function (_super) { __extends(TransformStream, _super); function TransformStream(reader) { @@ -86525,7 +86627,7 @@ exports.default = ReaderStream; /***/ }), -/* 747 */ +/* 749 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -86544,9 +86646,9 @@ var __extends = (this && this.__extends) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -var readdir = __webpack_require__(723); -var reader_1 = __webpack_require__(736); -var fs_sync_1 = __webpack_require__(748); +var readdir = __webpack_require__(725); +var reader_1 = __webpack_require__(738); +var fs_sync_1 = __webpack_require__(750); var ReaderSync = /** @class */ (function (_super) { __extends(ReaderSync, _super); function ReaderSync() { @@ -86606,7 +86708,7 @@ exports.default = ReaderSync; /***/ }), -/* 748 */ +/* 750 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -86625,8 +86727,8 @@ var __extends = (this && this.__extends) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -var fsStat = __webpack_require__(741); -var fs_1 = __webpack_require__(745); +var fsStat = __webpack_require__(743); +var fs_1 = __webpack_require__(747); var FileSystemSync = /** @class */ (function (_super) { __extends(FileSystemSync, _super); function FileSystemSync() { @@ -86672,7 +86774,7 @@ exports.default = FileSystemSync; /***/ }), -/* 749 */ +/* 751 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -86688,7 +86790,7 @@ exports.flatten = flatten; /***/ }), -/* 750 */ +/* 752 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -86709,13 +86811,13 @@ exports.merge = merge; /***/ }), -/* 751 */ +/* 753 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(4); -const pathType = __webpack_require__(752); +const pathType = __webpack_require__(754); const getExtensions = extensions => extensions.length > 1 ? `{${extensions.join(',')}}` : extensions[0]; @@ -86781,13 +86883,13 @@ module.exports.sync = (input, opts) => { /***/ }), -/* 752 */ +/* 754 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(134); -const pify = __webpack_require__(753); +const pify = __webpack_require__(755); function type(fn, fn2, fp) { if (typeof fp !== 'string') { @@ -86830,7 +86932,7 @@ exports.symlinkSync = typeSync.bind(null, 'lstatSync', 'isSymbolicLink'); /***/ }), -/* 753 */ +/* 755 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -86921,17 +87023,17 @@ module.exports = (obj, opts) => { /***/ }), -/* 754 */ +/* 756 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(134); const path = __webpack_require__(4); -const fastGlob = __webpack_require__(572); -const gitIgnore = __webpack_require__(755); -const pify = __webpack_require__(756); -const slash = __webpack_require__(757); +const fastGlob = __webpack_require__(574); +const gitIgnore = __webpack_require__(757); +const pify = __webpack_require__(758); +const slash = __webpack_require__(759); const DEFAULT_IGNORE = [ '**/node_modules/**', @@ -87029,7 +87131,7 @@ module.exports.sync = options => { /***/ }), -/* 755 */ +/* 757 */ /***/ (function(module, exports) { // A simple implementation of make-array @@ -87498,7 +87600,7 @@ module.exports = options => new IgnoreBase(options) /***/ }), -/* 756 */ +/* 758 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -87573,7 +87675,7 @@ module.exports = (input, options) => { /***/ }), -/* 757 */ +/* 759 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -87591,7 +87693,7 @@ module.exports = input => { /***/ }), -/* 758 */ +/* 760 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -87604,7 +87706,7 @@ module.exports = input => { -var isGlob = __webpack_require__(759); +var isGlob = __webpack_require__(761); module.exports = function hasGlob(val) { if (val == null) return false; @@ -87624,7 +87726,7 @@ module.exports = function hasGlob(val) { /***/ }), -/* 759 */ +/* 761 */ /***/ (function(module, exports, __webpack_require__) { /*! @@ -87655,17 +87757,17 @@ module.exports = function isGlob(str) { /***/ }), -/* 760 */ +/* 762 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(4); const {constants: fsConstants} = __webpack_require__(134); -const pEvent = __webpack_require__(761); -const CpFileError = __webpack_require__(764); -const fs = __webpack_require__(766); -const ProgressEmitter = __webpack_require__(769); +const pEvent = __webpack_require__(763); +const CpFileError = __webpack_require__(766); +const fs = __webpack_require__(768); +const ProgressEmitter = __webpack_require__(771); const cpFileAsync = async (source, destination, options, progressEmitter) => { let readError; @@ -87779,12 +87881,12 @@ module.exports.sync = (source, destination, options) => { /***/ }), -/* 761 */ +/* 763 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const pTimeout = __webpack_require__(762); +const pTimeout = __webpack_require__(764); const symbolAsyncIterator = Symbol.asyncIterator || '@@asyncIterator'; @@ -88075,12 +88177,12 @@ module.exports.iterator = (emitter, event, options) => { /***/ }), -/* 762 */ +/* 764 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const pFinally = __webpack_require__(763); +const pFinally = __webpack_require__(765); class TimeoutError extends Error { constructor(message) { @@ -88126,7 +88228,7 @@ module.exports.TimeoutError = TimeoutError; /***/ }), -/* 763 */ +/* 765 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -88148,12 +88250,12 @@ module.exports = (promise, onFinally) => { /***/ }), -/* 764 */ +/* 766 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const NestedError = __webpack_require__(765); +const NestedError = __webpack_require__(767); class CpFileError extends NestedError { constructor(message, nested) { @@ -88167,7 +88269,7 @@ module.exports = CpFileError; /***/ }), -/* 765 */ +/* 767 */ /***/ (function(module, exports, __webpack_require__) { var inherits = __webpack_require__(112).inherits; @@ -88223,16 +88325,16 @@ module.exports = NestedError; /***/ }), -/* 766 */ +/* 768 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const {promisify} = __webpack_require__(112); const fs = __webpack_require__(133); -const makeDir = __webpack_require__(767); -const pEvent = __webpack_require__(761); -const CpFileError = __webpack_require__(764); +const makeDir = __webpack_require__(769); +const pEvent = __webpack_require__(763); +const CpFileError = __webpack_require__(766); const stat = promisify(fs.stat); const lstat = promisify(fs.lstat); @@ -88329,7 +88431,7 @@ exports.copyFileSync = (source, destination, flags) => { /***/ }), -/* 767 */ +/* 769 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -88337,7 +88439,7 @@ exports.copyFileSync = (source, destination, flags) => { const fs = __webpack_require__(134); const path = __webpack_require__(4); const {promisify} = __webpack_require__(112); -const semver = __webpack_require__(768); +const semver = __webpack_require__(770); const useNativeRecursiveOption = semver.satisfies(process.version, '>=10.12.0'); @@ -88492,7 +88594,7 @@ module.exports.sync = (input, options) => { /***/ }), -/* 768 */ +/* 770 */ /***/ (function(module, exports) { exports = module.exports = SemVer @@ -90094,7 +90196,7 @@ function coerce (version, options) { /***/ }), -/* 769 */ +/* 771 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90135,7 +90237,7 @@ module.exports = ProgressEmitter; /***/ }), -/* 770 */ +/* 772 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90181,12 +90283,12 @@ exports.default = module.exports; /***/ }), -/* 771 */ +/* 773 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const pMap = __webpack_require__(772); +const pMap = __webpack_require__(774); const pFilter = async (iterable, filterer, options) => { const values = await pMap( @@ -90203,7 +90305,7 @@ module.exports.default = pFilter; /***/ }), -/* 772 */ +/* 774 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90282,12 +90384,12 @@ module.exports.default = pMap; /***/ }), -/* 773 */ +/* 775 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const NestedError = __webpack_require__(765); +const NestedError = __webpack_require__(767); class CpyError extends NestedError { constructor(message, nested) { @@ -90301,7 +90403,7 @@ module.exports = CpyError; /***/ }), -/* 774 */ +/* 776 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90309,10 +90411,10 @@ module.exports = CpyError; const fs = __webpack_require__(134); const arrayUnion = __webpack_require__(145); const merge2 = __webpack_require__(146); -const fastGlob = __webpack_require__(775); +const fastGlob = __webpack_require__(777); const dirGlob = __webpack_require__(232); -const gitignore = __webpack_require__(810); -const {FilterStream, UniqueStream} = __webpack_require__(811); +const gitignore = __webpack_require__(812); +const {FilterStream, UniqueStream} = __webpack_require__(813); const DEFAULT_FILTER = () => false; @@ -90489,17 +90591,17 @@ module.exports.gitignore = gitignore; /***/ }), -/* 775 */ +/* 777 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const taskManager = __webpack_require__(776); -const async_1 = __webpack_require__(796); -const stream_1 = __webpack_require__(806); -const sync_1 = __webpack_require__(807); -const settings_1 = __webpack_require__(809); -const utils = __webpack_require__(777); +const taskManager = __webpack_require__(778); +const async_1 = __webpack_require__(798); +const stream_1 = __webpack_require__(808); +const sync_1 = __webpack_require__(809); +const settings_1 = __webpack_require__(811); +const utils = __webpack_require__(779); async function FastGlob(source, options) { assertPatternsInput(source); const works = getWorks(source, async_1.default, options); @@ -90563,14 +90665,14 @@ module.exports = FastGlob; /***/ }), -/* 776 */ +/* 778 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.convertPatternGroupToTask = exports.convertPatternGroupsToTasks = exports.groupPatternsByBaseDirectory = exports.getNegativePatternsAsPositive = exports.getPositivePatterns = exports.convertPatternsToTasks = exports.generate = void 0; -const utils = __webpack_require__(777); +const utils = __webpack_require__(779); function generate(patterns, settings) { const positivePatterns = getPositivePatterns(patterns); const negativePatterns = getNegativePatternsAsPositive(patterns, settings.ignore); @@ -90635,31 +90737,31 @@ exports.convertPatternGroupToTask = convertPatternGroupToTask; /***/ }), -/* 777 */ +/* 779 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.string = exports.stream = exports.pattern = exports.path = exports.fs = exports.errno = exports.array = void 0; -const array = __webpack_require__(778); +const array = __webpack_require__(780); exports.array = array; -const errno = __webpack_require__(779); +const errno = __webpack_require__(781); exports.errno = errno; -const fs = __webpack_require__(780); +const fs = __webpack_require__(782); exports.fs = fs; -const path = __webpack_require__(781); +const path = __webpack_require__(783); exports.path = path; -const pattern = __webpack_require__(782); +const pattern = __webpack_require__(784); exports.pattern = pattern; -const stream = __webpack_require__(794); +const stream = __webpack_require__(796); exports.stream = stream; -const string = __webpack_require__(795); +const string = __webpack_require__(797); exports.string = string; /***/ }), -/* 778 */ +/* 780 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90688,7 +90790,7 @@ exports.splitWhen = splitWhen; /***/ }), -/* 779 */ +/* 781 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90702,7 +90804,7 @@ exports.isEnoentCodeError = isEnoentCodeError; /***/ }), -/* 780 */ +/* 782 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90728,7 +90830,7 @@ exports.createDirentFromStats = createDirentFromStats; /***/ }), -/* 781 */ +/* 783 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90768,7 +90870,7 @@ exports.removeLeadingDotSegment = removeLeadingDotSegment; /***/ }), -/* 782 */ +/* 784 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90777,7 +90879,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.matchAny = exports.convertPatternsToRe = exports.makeRe = exports.getPatternParts = exports.expandBraceExpansion = exports.expandPatternsWithBraceExpansion = exports.isAffectDepthOfReadingPattern = exports.endsWithSlashGlobStar = exports.hasGlobStar = exports.getBaseDirectory = exports.getPositivePatterns = exports.getNegativePatterns = exports.isPositivePattern = exports.isNegativePattern = exports.convertToNegativePattern = exports.convertToPositivePattern = exports.isDynamicPattern = exports.isStaticPattern = void 0; const path = __webpack_require__(4); const globParent = __webpack_require__(171); -const micromatch = __webpack_require__(783); +const micromatch = __webpack_require__(785); const picomatch = __webpack_require__(185); const GLOBSTAR = '**'; const ESCAPE_SYMBOL = '\\'; @@ -90907,14 +91009,14 @@ exports.matchAny = matchAny; /***/ }), -/* 783 */ +/* 785 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const util = __webpack_require__(112); -const braces = __webpack_require__(784); +const braces = __webpack_require__(786); const picomatch = __webpack_require__(185); const utils = __webpack_require__(188); const isEmptyString = val => typeof val === 'string' && (val === '' || val === './'); @@ -91381,16 +91483,16 @@ module.exports = micromatch; /***/ }), -/* 784 */ +/* 786 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const stringify = __webpack_require__(785); -const compile = __webpack_require__(787); -const expand = __webpack_require__(791); -const parse = __webpack_require__(792); +const stringify = __webpack_require__(787); +const compile = __webpack_require__(789); +const expand = __webpack_require__(793); +const parse = __webpack_require__(794); /** * Expand the given pattern or create a regex-compatible string. @@ -91558,13 +91660,13 @@ module.exports = braces; /***/ }), -/* 785 */ +/* 787 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const utils = __webpack_require__(786); +const utils = __webpack_require__(788); module.exports = (ast, options = {}) => { let stringify = (node, parent = {}) => { @@ -91597,7 +91699,7 @@ module.exports = (ast, options = {}) => { /***/ }), -/* 786 */ +/* 788 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -91716,14 +91818,14 @@ exports.flatten = (...args) => { /***/ }), -/* 787 */ +/* 789 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const fill = __webpack_require__(788); -const utils = __webpack_require__(786); +const fill = __webpack_require__(790); +const utils = __webpack_require__(788); const compile = (ast, options = {}) => { let walk = (node, parent = {}) => { @@ -91780,7 +91882,7 @@ module.exports = compile; /***/ }), -/* 788 */ +/* 790 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -91794,7 +91896,7 @@ module.exports = compile; const util = __webpack_require__(112); -const toRegexRange = __webpack_require__(789); +const toRegexRange = __webpack_require__(791); const isObject = val => val !== null && typeof val === 'object' && !Array.isArray(val); @@ -92036,7 +92138,7 @@ module.exports = fill; /***/ }), -/* 789 */ +/* 791 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -92049,7 +92151,7 @@ module.exports = fill; -const isNumber = __webpack_require__(790); +const isNumber = __webpack_require__(792); const toRegexRange = (min, max, options) => { if (isNumber(min) === false) { @@ -92331,7 +92433,7 @@ module.exports = toRegexRange; /***/ }), -/* 790 */ +/* 792 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -92356,15 +92458,15 @@ module.exports = function(num) { /***/ }), -/* 791 */ +/* 793 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const fill = __webpack_require__(788); -const stringify = __webpack_require__(785); -const utils = __webpack_require__(786); +const fill = __webpack_require__(790); +const stringify = __webpack_require__(787); +const utils = __webpack_require__(788); const append = (queue = '', stash = '', enclose = false) => { let result = []; @@ -92476,13 +92578,13 @@ module.exports = expand; /***/ }), -/* 792 */ +/* 794 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const stringify = __webpack_require__(785); +const stringify = __webpack_require__(787); /** * Constants @@ -92504,7 +92606,7 @@ const { CHAR_SINGLE_QUOTE, /* ' */ CHAR_NO_BREAK_SPACE, CHAR_ZERO_WIDTH_NOBREAK_SPACE -} = __webpack_require__(793); +} = __webpack_require__(795); /** * parse @@ -92816,7 +92918,7 @@ module.exports = parse; /***/ }), -/* 793 */ +/* 795 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -92880,7 +92982,7 @@ module.exports = { /***/ }), -/* 794 */ +/* 796 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -92904,7 +93006,7 @@ function propagateCloseEventToSources(streams) { /***/ }), -/* 795 */ +/* 797 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -92922,14 +93024,14 @@ exports.isEmpty = isEmpty; /***/ }), -/* 796 */ +/* 798 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const stream_1 = __webpack_require__(797); -const provider_1 = __webpack_require__(799); +const stream_1 = __webpack_require__(799); +const provider_1 = __webpack_require__(801); class ProviderAsync extends provider_1.default { constructor() { super(...arguments); @@ -92957,7 +93059,7 @@ exports.default = ProviderAsync; /***/ }), -/* 797 */ +/* 799 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -92966,7 +93068,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); const stream_1 = __webpack_require__(138); const fsStat = __webpack_require__(195); const fsWalk = __webpack_require__(200); -const reader_1 = __webpack_require__(798); +const reader_1 = __webpack_require__(800); class ReaderStream extends reader_1.default { constructor() { super(...arguments); @@ -93019,7 +93121,7 @@ exports.default = ReaderStream; /***/ }), -/* 798 */ +/* 800 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -93027,7 +93129,7 @@ exports.default = ReaderStream; Object.defineProperty(exports, "__esModule", { value: true }); const path = __webpack_require__(4); const fsStat = __webpack_require__(195); -const utils = __webpack_require__(777); +const utils = __webpack_require__(779); class Reader { constructor(_settings) { this._settings = _settings; @@ -93059,17 +93161,17 @@ exports.default = Reader; /***/ }), -/* 799 */ +/* 801 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const path = __webpack_require__(4); -const deep_1 = __webpack_require__(800); -const entry_1 = __webpack_require__(803); -const error_1 = __webpack_require__(804); -const entry_2 = __webpack_require__(805); +const deep_1 = __webpack_require__(802); +const entry_1 = __webpack_require__(805); +const error_1 = __webpack_require__(806); +const entry_2 = __webpack_require__(807); class Provider { constructor(_settings) { this._settings = _settings; @@ -93114,14 +93216,14 @@ exports.default = Provider; /***/ }), -/* 800 */ +/* 802 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const utils = __webpack_require__(777); -const partial_1 = __webpack_require__(801); +const utils = __webpack_require__(779); +const partial_1 = __webpack_require__(803); class DeepFilter { constructor(_settings, _micromatchOptions) { this._settings = _settings; @@ -93183,13 +93285,13 @@ exports.default = DeepFilter; /***/ }), -/* 801 */ +/* 803 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const matcher_1 = __webpack_require__(802); +const matcher_1 = __webpack_require__(804); class PartialMatcher extends matcher_1.default { match(filepath) { const parts = filepath.split('/'); @@ -93228,13 +93330,13 @@ exports.default = PartialMatcher; /***/ }), -/* 802 */ +/* 804 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const utils = __webpack_require__(777); +const utils = __webpack_require__(779); class Matcher { constructor(_patterns, _settings, _micromatchOptions) { this._patterns = _patterns; @@ -93285,13 +93387,13 @@ exports.default = Matcher; /***/ }), -/* 803 */ +/* 805 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const utils = __webpack_require__(777); +const utils = __webpack_require__(779); class EntryFilter { constructor(_settings, _micromatchOptions) { this._settings = _settings; @@ -93348,13 +93450,13 @@ exports.default = EntryFilter; /***/ }), -/* 804 */ +/* 806 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const utils = __webpack_require__(777); +const utils = __webpack_require__(779); class ErrorFilter { constructor(_settings) { this._settings = _settings; @@ -93370,13 +93472,13 @@ exports.default = ErrorFilter; /***/ }), -/* 805 */ +/* 807 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const utils = __webpack_require__(777); +const utils = __webpack_require__(779); class EntryTransformer { constructor(_settings) { this._settings = _settings; @@ -93403,15 +93505,15 @@ exports.default = EntryTransformer; /***/ }), -/* 806 */ +/* 808 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const stream_1 = __webpack_require__(138); -const stream_2 = __webpack_require__(797); -const provider_1 = __webpack_require__(799); +const stream_2 = __webpack_require__(799); +const provider_1 = __webpack_require__(801); class ProviderStream extends provider_1.default { constructor() { super(...arguments); @@ -93441,14 +93543,14 @@ exports.default = ProviderStream; /***/ }), -/* 807 */ +/* 809 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const sync_1 = __webpack_require__(808); -const provider_1 = __webpack_require__(799); +const sync_1 = __webpack_require__(810); +const provider_1 = __webpack_require__(801); class ProviderSync extends provider_1.default { constructor() { super(...arguments); @@ -93471,7 +93573,7 @@ exports.default = ProviderSync; /***/ }), -/* 808 */ +/* 810 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -93479,7 +93581,7 @@ exports.default = ProviderSync; Object.defineProperty(exports, "__esModule", { value: true }); const fsStat = __webpack_require__(195); const fsWalk = __webpack_require__(200); -const reader_1 = __webpack_require__(798); +const reader_1 = __webpack_require__(800); class ReaderSync extends reader_1.default { constructor() { super(...arguments); @@ -93521,7 +93623,7 @@ exports.default = ReaderSync; /***/ }), -/* 809 */ +/* 811 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -93585,7 +93687,7 @@ exports.default = Settings; /***/ }), -/* 810 */ +/* 812 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -93593,7 +93695,7 @@ exports.default = Settings; const {promisify} = __webpack_require__(112); const fs = __webpack_require__(134); const path = __webpack_require__(4); -const fastGlob = __webpack_require__(775); +const fastGlob = __webpack_require__(777); const gitIgnore = __webpack_require__(235); const slash = __webpack_require__(236); @@ -93712,7 +93814,7 @@ module.exports.sync = options => { /***/ }), -/* 811 */ +/* 813 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -93765,7 +93867,7 @@ module.exports = { /***/ }), -/* 812 */ +/* 814 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -93773,13 +93875,13 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "buildNonBazelProductionProjects", function() { return buildNonBazelProductionProjects; }); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "getProductionProjects", function() { return getProductionProjects; }); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "buildProject", function() { return buildProject; }); -/* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(565); +/* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(567); /* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(cpy__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(143); /* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(del__WEBPACK_IMPORTED_MODULE_1__); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(4); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_2__); -/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(562); +/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(564); /* harmony import */ var _utils_fs__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(131); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(246); /* harmony import */ var _utils_package_json__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(251); diff --git a/packages/kbn-pm/src/commands/bootstrap.ts b/packages/kbn-pm/src/commands/bootstrap.ts index b383a52be63f50..bad6eef3266f89 100644 --- a/packages/kbn-pm/src/commands/bootstrap.ts +++ b/packages/kbn-pm/src/commands/bootstrap.ts @@ -66,7 +66,7 @@ export const BootstrapCommand: ICommand = { await runBazel(['run', '@nodejs//:yarn'], runOffline); } - await runBazel(['build', '//packages:build'], runOffline); + await runBazel(['build', '//packages:build', '--show_result=1'], runOffline); // Install monorepo npm dependencies outside of the Bazel managed ones for (const batch of batchedNonBazelProjects) { diff --git a/packages/kbn-pm/src/commands/build_bazel.ts b/packages/kbn-pm/src/commands/build_bazel.ts new file mode 100644 index 00000000000000..f71e2e96e31b0e --- /dev/null +++ b/packages/kbn-pm/src/commands/build_bazel.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { runBazel } from '../utils/bazel'; +import { ICommand } from './'; + +export const BuildBazelCommand: ICommand = { + description: 'Runs a build in the Bazel built packages', + name: 'build-bazel', + + async run(projects, projectGraph, { options }) { + const runOffline = options?.offline === true; + + // Call bazel with the target to build all available packages + await runBazel(['build', '//packages:build', '--show_result=1'], runOffline); + }, +}; diff --git a/packages/kbn-pm/src/commands/index.ts b/packages/kbn-pm/src/commands/index.ts index 0ab6bc9c7808a3..2f5c04c2f434f1 100644 --- a/packages/kbn-pm/src/commands/index.ts +++ b/packages/kbn-pm/src/commands/index.ts @@ -27,16 +27,20 @@ export interface ICommand { } import { BootstrapCommand } from './bootstrap'; +import { BuildBazelCommand } from './build_bazel'; import { CleanCommand } from './clean'; import { ResetCommand } from './reset'; import { RunCommand } from './run'; import { WatchCommand } from './watch'; +import { WatchBazelCommand } from './watch_bazel'; import { Kibana } from '../utils/kibana'; export const commands: { [key: string]: ICommand } = { bootstrap: BootstrapCommand, + 'build-bazel': BuildBazelCommand, clean: CleanCommand, reset: ResetCommand, run: RunCommand, watch: WatchCommand, + 'watch-bazel': WatchBazelCommand, }; diff --git a/packages/kbn-pm/src/commands/run.ts b/packages/kbn-pm/src/commands/run.ts index 5535fe0d8358f8..9a3a19d9e625ed 100644 --- a/packages/kbn-pm/src/commands/run.ts +++ b/packages/kbn-pm/src/commands/run.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import dedent from 'dedent'; import { CliError } from '../utils/errors'; import { log } from '../utils/log'; import { parallelizeBatches } from '../utils/parallelize'; @@ -13,10 +14,17 @@ import { topologicallyBatchProjects } from '../utils/projects'; import { ICommand } from './'; export const RunCommand: ICommand = { - description: 'Run script defined in package.json in each package that contains that script.', + description: + 'Run script defined in package.json in each package that contains that script (only works on packages not using Bazel yet)', name: 'run', async run(projects, projectGraph, { extraArgs, options }) { + log.warning(dedent` + We are migrating packages into the Bazel build system and we will no longer support running npm scripts on + packages using 'yarn kbn run' on Bazel built packages. If the package you are trying to act on contains a + BUILD.bazel file please just use 'yarn kbn build-bazel' to build it or 'yarn kbn watch-bazel' to watch it + `); + const batchedProjects = topologicallyBatchProjects(projects, projectGraph); if (extraArgs.length === 0) { diff --git a/packages/kbn-pm/src/commands/watch.ts b/packages/kbn-pm/src/commands/watch.ts index fb398d68521369..5d0f6d086d3e80 100644 --- a/packages/kbn-pm/src/commands/watch.ts +++ b/packages/kbn-pm/src/commands/watch.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import dedent from 'dedent'; import { CliError } from '../utils/errors'; import { log } from '../utils/log'; import { parallelizeBatches } from '../utils/parallelize'; @@ -34,10 +35,16 @@ const kibanaProjectName = 'kibana'; * `webpack` and `tsc` only, for the rest we rely on predefined timeouts. */ export const WatchCommand: ICommand = { - description: 'Runs `kbn:watch` script for every project.', + description: + 'Runs `kbn:watch` script for every project (only works on packages not using Bazel yet)', name: 'watch', async run(projects, projectGraph) { + log.warning(dedent` + We are migrating packages into the Bazel build system. If the package you are trying to watch + contains a BUILD.bazel file please just use 'yarn kbn watch-bazel' + `); + const projectsToWatch: ProjectMap = new Map(); for (const project of projects.values()) { // We can't watch project that doesn't have `kbn:watch` script. diff --git a/packages/kbn-pm/src/commands/watch_bazel.ts b/packages/kbn-pm/src/commands/watch_bazel.ts new file mode 100644 index 00000000000000..1273562dd25116 --- /dev/null +++ b/packages/kbn-pm/src/commands/watch_bazel.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { runIBazel } from '../utils/bazel'; +import { ICommand } from './'; + +export const WatchBazelCommand: ICommand = { + description: 'Runs a build in the Bazel built packages and keeps watching them for changes', + name: 'watch-bazel', + + async run(projects, projectGraph, { options }) { + const runOffline = options?.offline === true; + + // Call bazel with the target to build all available packages and run it through iBazel to watch it for changes + // + // Note: --run_output=false arg will disable the iBazel notifications about gazelle and buildozer when running it + // Can also be solved by adding a root `.bazel_fix_commands.json` but its not needed at the moment + await runIBazel(['--run_output=false', 'build', '//packages:build'], runOffline); + }, +}; diff --git a/packages/kbn-pm/src/utils/bazel/run.ts b/packages/kbn-pm/src/utils/bazel/run.ts index ab20150768b780..34718606db98e4 100644 --- a/packages/kbn-pm/src/utils/bazel/run.ts +++ b/packages/kbn-pm/src/utils/bazel/run.ts @@ -13,8 +13,12 @@ import { tap } from 'rxjs/operators'; import { observeLines } from '@kbn/dev-utils/stdio'; import { spawn } from '../child_process'; import { log } from '../log'; +import { CliError } from '../errors'; -export async function runBazel( +type BazelCommandRunner = 'bazel' | 'ibazel'; + +async function runBazelCommandWithRunner( + bazelCommandRunner: BazelCommandRunner, bazelArgs: string[], offline: boolean = false, runOpts: execa.Options = {} @@ -29,7 +33,7 @@ export async function runBazel( bazelArgs.push('--config=offline'); } - const bazelProc = spawn('bazel', bazelArgs, bazelOpts); + const bazelProc = spawn(bazelCommandRunner, bazelArgs, bazelOpts); const bazelLogs$ = new Rx.Subject(); @@ -37,15 +41,35 @@ export async function runBazel( // Therefore we need to get both. In order to get errors we need to parse the actual text line const bazelLogSubscription = Rx.merge( observeLines(bazelProc.stdout!).pipe( - tap((line) => log.info(`${chalk.cyan('[bazel]')} ${line}`)) + tap((line) => log.info(`${chalk.cyan(`[${bazelCommandRunner}]`)} ${line}`)) ), observeLines(bazelProc.stderr!).pipe( - tap((line) => log.info(`${chalk.cyan('[bazel]')} ${line}`)) + tap((line) => log.info(`${chalk.cyan(`[${bazelCommandRunner}]`)} ${line}`)) ) ).subscribe(bazelLogs$); // Wait for process and logs to finish, unsubscribing in the end - await bazelProc; + try { + await bazelProc; + } catch { + throw new CliError(`The bazel command that was running failed to complete.`); + } await bazelLogs$.toPromise(); await bazelLogSubscription.unsubscribe(); } + +export async function runBazel( + bazelArgs: string[], + offline: boolean = false, + runOpts: execa.Options = {} +) { + await runBazelCommandWithRunner('bazel', bazelArgs, offline, runOpts); +} + +export async function runIBazel( + bazelArgs: string[], + offline: boolean = false, + runOpts: execa.Options = {} +) { + await runBazelCommandWithRunner('ibazel', bazelArgs, offline, runOpts); +} From 4f6bd31c912c46254853bfba1886b56a63c9ffd2 Mon Sep 17 00:00:00 2001 From: ymao1 Date: Mon, 12 Apr 2021 21:12:45 -0400 Subject: [PATCH 06/90] [Alerting] Fixing `notifyWhen` terminology (#96490) * Updating terminology * Updating wording * Updating wording --- docs/user/alerting/defining-rules.asciidoc | 4 ++-- .../application/sections/alert_form/alert_notify_when.tsx | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/user/alerting/defining-rules.asciidoc b/docs/user/alerting/defining-rules.asciidoc index 63839cf465e983..05885f1af13ba2 100644 --- a/docs/user/alerting/defining-rules.asciidoc +++ b/docs/user/alerting/defining-rules.asciidoc @@ -28,8 +28,8 @@ Name:: The name of the rule. While this name does not have to be unique, th Tags:: A list of tag names that can be applied to a rule. Tags can help you organize and find rules, because tags appear in the rule listing in the management UI which is searchable by tag. Check every:: This value determines how frequently the rule conditions below are checked. Note that the timing of background rule checks are not guaranteed, particularly for intervals of less than 10 seconds. See <> for more information. Notify:: This value limits how often actions are repeated when an alert remains active across rule checks. See <> for more information. + -- **Only on status change**: Actions are not repeated when an alert remains active across checks. Actions run only when the rule status changes. -- **Every time rule is active**: Actions are repeated when an alert remains active across checks. +- **Only on status change**: Actions are not repeated when an alert remains active across checks. Actions run only when the alert status changes. +- **Every time alert is active**: Actions are repeated when an alert remains active across checks. - **On a custom action interval**: Actions are suppressed for the throttle interval, but repeat when an alert remains active across checks for a duration longer than the throttle interval. diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_notify_when.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_notify_when.tsx index 95fbe9c6ae6149..b774fd702fadc4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_notify_when.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_notify_when.tsx @@ -49,7 +49,7 @@ const NOTIFY_WHEN_OPTIONS: Array> = [

@@ -62,7 +62,7 @@ const NOTIFY_WHEN_OPTIONS: Array> = [ inputDisplay: i18n.translate( 'xpack.triggersActionsUI.sections.alertForm.alertNotifyWhen.onActiveAlert.display', { - defaultMessage: 'Every time rule is active', + defaultMessage: 'Every time alert is active', } ), 'data-test-subj': 'onActiveAlert', @@ -70,14 +70,14 @@ const NOTIFY_WHEN_OPTIONS: Array> = [

From 9a10bcb6526c3e7773a278462de732d1b8df70ec Mon Sep 17 00:00:00 2001 From: Andrew Pease <7442091+peasead@users.noreply.github.com> Date: Mon, 12 Apr 2021 21:57:04 -0500 Subject: [PATCH 07/90] Update README.md - broken params env link (#95820) ## Summary The link to set the params env was broken. ### Checklist Delete any items that are not applicable to this PR. - [x] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- .../lib/detection_engine/rules/prepackaged_timelines/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/README.md b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/README.md index 901dacbfe80cc0..1b8516ee160125 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/README.md +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/README.md @@ -4,7 +4,7 @@ -1. [Have the env params set up](https://github.com/elastic/kibana/blob/master/x-pack/plugins/siem/server/lib/detection_engine/README.md) +1. [Have the env params set up](https://github.com/elastic/kibana/blob/master/x-pack/plugins/security_solution/server/lib/detection_engine/README.md) 2. Create a new timelines template into `x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines` From 0da55781826385461148942e4857f2436080700e Mon Sep 17 00:00:00 2001 From: Shahzad Date: Tue, 13 Apr 2021 08:19:26 +0200 Subject: [PATCH 08/90] [Data] Pass field meta to value suggestions api (#96239) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../providers/value_suggestion_provider.ts | 2 +- .../server/autocomplete/value_suggestions_route.ts | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.ts b/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.ts index b8af6ad3a99e58..3dda97566da5ad 100644 --- a/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.ts +++ b/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.ts @@ -59,7 +59,7 @@ export const setupValueSuggestionProvider = ( return core.http .fetch(`/api/kibana/suggestions/values/${index}`, { method: 'POST', - body: JSON.stringify({ query, field: field.name, filters }), + body: JSON.stringify({ query, field: field.name, fieldMeta: field?.toSpec?.(), filters }), signal, }) .then((r) => { diff --git a/src/plugins/data/server/autocomplete/value_suggestions_route.ts b/src/plugins/data/server/autocomplete/value_suggestions_route.ts index bdcc13ce4c061a..f0487b93b8ee53 100644 --- a/src/plugins/data/server/autocomplete/value_suggestions_route.ts +++ b/src/plugins/data/server/autocomplete/value_suggestions_route.ts @@ -36,6 +36,7 @@ export function registerValueSuggestionsRoute( field: schema.string(), query: schema.string(), filters: schema.maybe(schema.any()), + fieldMeta: schema.maybe(schema.any()), }, { unknowns: 'allow' } ), @@ -43,7 +44,7 @@ export function registerValueSuggestionsRoute( }, async (context, request, response) => { const config = await config$.pipe(first()).toPromise(); - const { field: fieldName, query, filters } = request.body; + const { field: fieldName, query, filters, fieldMeta } = request.body; const { index } = request.params; const { client } = context.core.elasticsearch.legacy; const signal = getRequestAbortedSignal(request.events.aborted$); @@ -53,9 +54,14 @@ export function registerValueSuggestionsRoute( terminate_after: config.kibana.autocompleteTerminateAfter.asMilliseconds(), }; - const indexPattern = await findIndexPatternById(context.core.savedObjects.client, index); + let field: IFieldType | undefined = fieldMeta; + + if (!field?.name && !field?.type) { + const indexPattern = await findIndexPatternById(context.core.savedObjects.client, index); + + field = indexPattern && getFieldByName(fieldName, indexPattern); + } - const field = indexPattern && getFieldByName(fieldName, indexPattern); const body = await getBody(autocompleteSearchOptions, field || fieldName, query, filters); const result = await client.callAsCurrentUser('search', { index, body }, { signal }); From d7a09e4dc53232e38bba2fc1e1521e7793594304 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 13 Apr 2021 09:54:40 +0300 Subject: [PATCH 09/90] [Security Solution][Cases] Fix create case flyout on timeline. (#96798) --- .../public/cases/components/create/flyout.tsx | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/security_solution/public/cases/components/create/flyout.tsx b/x-pack/plugins/security_solution/public/cases/components/create/flyout.tsx index e7bb0b25f391fd..8f76ee8f851738 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/flyout.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/flyout.tsx @@ -33,11 +33,25 @@ const StyledFlyout = styled(EuiFlyout)` z-index: ${theme.eui.euiZModal}; `} `; - // Adding bottom padding because timeline's // bottom bar gonna hide the submit button. +const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` + ${({ theme }) => ` + && .euiFlyoutBody__overflow { + overflow-y: auto; + overflow-x: hidden; + } + + && .euiFlyoutBody__overflowContent { + display: block; + padding: ${theme.eui.paddingSizes.l} ${theme.eui.paddingSizes.l} 70px; + height: auto; + } + `} +`; + const FormWrapper = styled.div` - padding-bottom: 50px; + width: 100%; `; const CreateCaseFlyoutComponent: React.FC = ({ @@ -52,7 +66,7 @@ const CreateCaseFlyoutComponent: React.FC = ({

{i18n.CREATE_TITLE}

- + @@ -61,7 +75,7 @@ const CreateCaseFlyoutComponent: React.FC = ({ - + ); }; From ebfbe6fc8cb99fa8f67b9094fab55968b1b7e2b8 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 13 Apr 2021 09:45:21 +0200 Subject: [PATCH 10/90] close popover on dragging (#96784) --- x-pack/plugins/lens/public/drag_drop/drag_drop.tsx | 14 ++++++++++---- .../public/indexpattern_datasource/field_item.tsx | 5 +++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx index 88de9154ffc349..51021a3e50b3f2 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx @@ -44,6 +44,16 @@ interface BaseProps { * is dropped onto this DragDrop component. */ onDrop?: DropHandler; + /** + * The event handler that fires when this element is dragged. + */ + onDragStart?: ( + target?: DroppableEvent['currentTarget'] | KeyboardEvent['currentTarget'] + ) => void; + /** + * The event handler that fires when the dragging of this element ends. + */ + onDragEnd?: () => void; /** * The value associated with this item. */ @@ -116,10 +126,6 @@ interface DragInnerProps extends BaseProps { activeDropTarget: DragContextState['activeDropTarget']; dropTargetsByOrder: DragContextState['dropTargetsByOrder']; }; - onDragStart?: ( - target?: DroppableEvent['currentTarget'] | KeyboardEvent['currentTarget'] - ) => void; - onDragEnd?: () => void; extraKeyboardHandler?: (e: KeyboardEvent) => void; ariaDescribedBy?: string; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx index 8ae62e4d843c28..2da79020383453 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx @@ -193,6 +193,10 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) { } } + const onDragStart = useCallback(() => { + setOpen(false); + }, [setOpen]); + const value = useMemo( () => ({ field, @@ -244,6 +248,7 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) { order={order} value={value} dataTestSubj={`lnsFieldListPanelField-${field.name}`} + onDragStart={onDragStart} > Date: Tue, 13 Apr 2021 10:42:19 +0200 Subject: [PATCH 11/90] [Graph] Map request failure for text fields with better error message (#96777) --- x-pack/plugins/graph/server/routes/explore.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/plugins/graph/server/routes/explore.ts b/x-pack/plugins/graph/server/routes/explore.ts index 9a9a267c40f32a..7109eee3b91114 100644 --- a/x-pack/plugins/graph/server/routes/explore.ts +++ b/x-pack/plugins/graph/server/routes/explore.ts @@ -67,6 +67,7 @@ export function registerExploreRoute({ cause.reason.includes('No support for examining floating point') || cause.reason.includes('Sample diversifying key must be a single valued-field') || cause.reason.includes('Failed to parse query') || + cause.reason.includes('Text fields are not optimised for operations') || cause.type === 'parsing_exception' ); }); From f31e13c42625da7ee04368a7e14f4001ea5ce371 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Tue, 13 Apr 2021 10:51:43 +0200 Subject: [PATCH 12/90] [Ingest Pipelines] Migrate to new ES client (#96406) * - migrated use of legacy.client to client - removed use of isEsError to detect legacy errors - refactored types to use types from @elastic/elasticsearch instead (where appropriate) tested get, put, post, delete, simulate and documents endpoints locally * remove use of legacyEs service in functional test * fixing type issues and API response object * remove id from get all request! * reinstated logic for handling 404 from get all pipelines request * clarify error handling with comments and small variable name refactor * updated delete error responses * update functional test * refactor use of legacyEs Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../errors/handle_es_error.ts | 4 +- .../common/lib/pipeline_serialization.ts | 9 +++-- .../plugins/ingest_pipelines/common/types.ts | 2 +- .../plugins/ingest_pipelines/server/plugin.ts | 4 +- .../server/routes/api/create.ts | 27 ++++--------- .../server/routes/api/delete.ts | 14 ++++--- .../server/routes/api/documents.ts | 15 ++------ .../ingest_pipelines/server/routes/api/get.ts | 38 +++++++------------ .../server/routes/api/privileges.ts | 23 +++-------- .../server/routes/api/shared/index.ts | 2 +- .../routes/api/shared/is_object_with_keys.ts | 10 ----- .../api/{ => shared}/pipeline_schema.ts | 0 .../server/routes/api/simulate.ts | 21 ++++------ .../server/routes/api/update.ts | 25 +++--------- .../ingest_pipelines/server/shared_imports.ts | 2 +- .../plugins/ingest_pipelines/server/types.ts | 4 +- .../ingest_pipelines/ingest_pipelines.ts | 26 +++++-------- .../ingest_pipelines/lib/elasticsearch.ts | 11 +++--- .../apps/ingest_pipelines/ingest_pipelines.ts | 2 +- 19 files changed, 85 insertions(+), 154 deletions(-) delete mode 100644 x-pack/plugins/ingest_pipelines/server/routes/api/shared/is_object_with_keys.ts rename x-pack/plugins/ingest_pipelines/server/routes/api/{ => shared}/pipeline_schema.ts (100%) diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/errors/handle_es_error.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/errors/handle_es_error.ts index 42e18b72057ce3..6a308203fcc279 100644 --- a/src/plugins/es_ui_shared/__packages_do_not_import__/errors/handle_es_error.ts +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/errors/handle_es_error.ts @@ -17,8 +17,10 @@ interface EsErrorHandlerParams { handleCustomError?: () => IKibanaResponse; } -/* +/** * For errors returned by the new elasticsearch js client. + * + * @throws If "error" is not an error from the elasticsearch client this handler will throw "error". */ export const handleEsError = ({ error, diff --git a/x-pack/plugins/ingest_pipelines/common/lib/pipeline_serialization.ts b/x-pack/plugins/ingest_pipelines/common/lib/pipeline_serialization.ts index 997f14fd5e28dd..5360e2713aee19 100644 --- a/x-pack/plugins/ingest_pipelines/common/lib/pipeline_serialization.ts +++ b/x-pack/plugins/ingest_pipelines/common/lib/pipeline_serialization.ts @@ -5,14 +5,17 @@ * 2.0. */ -import { PipelinesByName, Pipeline } from '../types'; +import { Pipeline as ESPipeline } from '@elastic/elasticsearch/api/types'; +import { Pipeline, Processor } from '../types'; -export function deserializePipelines(pipelinesByName: PipelinesByName): Pipeline[] { +export function deserializePipelines(pipelinesByName: { [key: string]: ESPipeline }): Pipeline[] { const pipelineNames: string[] = Object.keys(pipelinesByName); - const deserializedPipelines = pipelineNames.map((name: string) => { + const deserializedPipelines = pipelineNames.map((name: string) => { return { ...pipelinesByName[name], + processors: (pipelinesByName[name]?.processors as Processor[]) ?? [], + on_failure: pipelinesByName[name]?.on_failure as Processor[], name, }; }); diff --git a/x-pack/plugins/ingest_pipelines/common/types.ts b/x-pack/plugins/ingest_pipelines/common/types.ts index 5a8bed206175aa..303db8423d4016 100644 --- a/x-pack/plugins/ingest_pipelines/common/types.ts +++ b/x-pack/plugins/ingest_pipelines/common/types.ts @@ -19,7 +19,7 @@ export interface Processor { export interface Pipeline { name: string; - description: string; + description?: string; version?: number; processors: Processor[]; on_failure?: Processor[]; diff --git a/x-pack/plugins/ingest_pipelines/server/plugin.ts b/x-pack/plugins/ingest_pipelines/server/plugin.ts index 23accb49ba57b9..7e2f7d5e82e337 100644 --- a/x-pack/plugins/ingest_pipelines/server/plugin.ts +++ b/x-pack/plugins/ingest_pipelines/server/plugin.ts @@ -13,7 +13,7 @@ import { PLUGIN_ID, PLUGIN_MIN_LICENSE_TYPE } from '../common/constants'; import { License } from './services'; import { ApiRoutes } from './routes'; -import { isEsError } from './shared_imports'; +import { handleEsError } from './shared_imports'; import { Dependencies } from './types'; export class IngestPipelinesPlugin implements Plugin { @@ -66,7 +66,7 @@ export class IngestPipelinesPlugin implements Plugin { isSecurityEnabled: () => security !== undefined && security.license.isEnabled(), }, lib: { - isEsError, + handleEsError, }, }); } diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/create.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/create.ts index afa36e5abe31a1..388c82aa34b3d1 100644 --- a/x-pack/plugins/ingest_pipelines/server/routes/api/create.ts +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/create.ts @@ -11,8 +11,7 @@ import { schema } from '@kbn/config-schema'; import { Pipeline } from '../../../common/types'; import { API_BASE_PATH } from '../../../common/constants'; import { RouteDependencies } from '../../types'; -import { pipelineSchema } from './pipeline_schema'; -import { isObjectWithKeys } from './shared'; +import { pipelineSchema } from './shared'; const bodySchema = schema.object({ name: schema.string(), @@ -22,7 +21,7 @@ const bodySchema = schema.object({ export const registerCreateRoute = ({ router, license, - lib: { isEsError }, + lib: { handleEsError }, }: RouteDependencies): void => { router.post( { @@ -32,7 +31,7 @@ export const registerCreateRoute = ({ }, }, license.guardApiRoute(async (ctx, req, res) => { - const { callAsCurrentUser } = ctx.core.elasticsearch.legacy.client; + const { client: clusterClient } = ctx.core.elasticsearch; const pipeline = req.body as Pipeline; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -40,7 +39,9 @@ export const registerCreateRoute = ({ try { // Check that a pipeline with the same name doesn't already exist - const pipelineByName = await callAsCurrentUser('ingest.getPipeline', { id: name }); + const { body: pipelineByName } = await clusterClient.asCurrentUser.ingest.getPipeline({ + id: name, + }); if (pipelineByName[name]) { return res.conflict({ @@ -59,7 +60,7 @@ export const registerCreateRoute = ({ } try { - const response = await callAsCurrentUser('ingest.putPipeline', { + const { body: response } = await clusterClient.asCurrentUser.ingest.putPipeline({ id: name, body: { description, @@ -71,19 +72,7 @@ export const registerCreateRoute = ({ return res.ok({ body: response }); } catch (error) { - if (isEsError(error)) { - return res.customError({ - statusCode: error.statusCode, - body: isObjectWithKeys(error.body) - ? { - message: error.message, - attributes: error.body, - } - : error, - }); - } - - throw error; + return handleEsError({ error, response: res }); } }) ); diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/delete.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/delete.ts index f30b3a49f5fe1e..8cc7d7044ad08e 100644 --- a/x-pack/plugins/ingest_pipelines/server/routes/api/delete.ts +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/delete.ts @@ -23,7 +23,7 @@ export const registerDeleteRoute = ({ router, license }: RouteDependencies): voi }, }, license.guardApiRoute(async (ctx, req, res) => { - const { callAsCurrentUser } = ctx.core.elasticsearch.legacy.client; + const { client: clusterClient } = ctx.core.elasticsearch; const { names } = req.params; const pipelineNames = names.split(','); @@ -34,14 +34,16 @@ export const registerDeleteRoute = ({ router, license }: RouteDependencies): voi await Promise.all( pipelineNames.map((pipelineName) => { - return callAsCurrentUser('ingest.deletePipeline', { id: pipelineName }) + return clusterClient.asCurrentUser.ingest + .deletePipeline({ id: pipelineName }) .then(() => response.itemsDeleted.push(pipelineName)) - .catch((e) => + .catch((e) => { response.errors.push({ + error: e?.meta?.body?.error ?? e, + status: e?.meta?.body?.status, name: pipelineName, - error: e, - }) - ); + }); + }); }) ); diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/documents.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/documents.ts index 635ee015be5162..324bcdd3edb462 100644 --- a/x-pack/plugins/ingest_pipelines/server/routes/api/documents.ts +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/documents.ts @@ -18,7 +18,7 @@ const paramsSchema = schema.object({ export const registerDocumentsRoute = ({ router, license, - lib: { isEsError }, + lib: { handleEsError }, }: RouteDependencies): void => { router.get( { @@ -28,11 +28,11 @@ export const registerDocumentsRoute = ({ }, }, license.guardApiRoute(async (ctx, req, res) => { - const { callAsCurrentUser } = ctx.core.elasticsearch.legacy.client; + const { client: clusterClient } = ctx.core.elasticsearch; const { index, id } = req.params; try { - const document = await callAsCurrentUser('get', { index, id }); + const { body: document } = await clusterClient.asCurrentUser.get({ index, id }); const { _id, _index, _source } = document; @@ -44,14 +44,7 @@ export const registerDocumentsRoute = ({ }, }); } catch (error) { - if (isEsError(error)) { - return res.customError({ - statusCode: error.statusCode, - body: error, - }); - } - - throw error; + return handleEsError({ error, response: res }); } }) ); diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/get.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/get.ts index 3995448d13fbb9..853bd1c7dde238 100644 --- a/x-pack/plugins/ingest_pipelines/server/routes/api/get.ts +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/get.ts @@ -18,33 +18,26 @@ const paramsSchema = schema.object({ export const registerGetRoutes = ({ router, license, - lib: { isEsError }, + lib: { handleEsError }, }: RouteDependencies): void => { // Get all pipelines router.get( { path: API_BASE_PATH, validate: false }, license.guardApiRoute(async (ctx, req, res) => { - const { callAsCurrentUser } = ctx.core.elasticsearch.legacy.client; + const { client: clusterClient } = ctx.core.elasticsearch; try { - const pipelines = await callAsCurrentUser('ingest.getPipeline'); + const { body: pipelines } = await clusterClient.asCurrentUser.ingest.getPipeline(); return res.ok({ body: deserializePipelines(pipelines) }); } catch (error) { - if (isEsError(error)) { + const esErrorResponse = handleEsError({ error, response: res }); + if (esErrorResponse.status === 404) { // ES returns 404 when there are no pipelines // Instead, we return an empty array and 200 status back to the client - if (error.status === 404) { - return res.ok({ body: [] }); - } - - return res.customError({ - statusCode: error.statusCode, - body: error, - }); + return res.ok({ body: [] }); } - - throw error; + return esErrorResponse; } }) ); @@ -58,27 +51,22 @@ export const registerGetRoutes = ({ }, }, license.guardApiRoute(async (ctx, req, res) => { - const { callAsCurrentUser } = ctx.core.elasticsearch.legacy.client; + const { client: clusterClient } = ctx.core.elasticsearch; const { name } = req.params; try { - const pipeline = await callAsCurrentUser('ingest.getPipeline', { id: name }); + const { body: pipelines } = await clusterClient.asCurrentUser.ingest.getPipeline({ + id: name, + }); return res.ok({ body: { - ...pipeline[name], + ...pipelines[name], name, }, }); } catch (error) { - if (isEsError(error)) { - return res.customError({ - statusCode: error.statusCode, - body: error, - }); - } - - throw error; + return handleEsError({ error, response: res }); } }) ); diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/privileges.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/privileges.ts index 527b4d4277bf5f..e1e4b2d3d28866 100644 --- a/x-pack/plugins/ingest_pipelines/server/routes/api/privileges.ts +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/privileges.ts @@ -36,24 +36,13 @@ export const registerPrivilegesRoute = ({ license, router, config }: RouteDepend return res.ok({ body: privilegesResult }); } - const { - core: { - elasticsearch: { - legacy: { client }, - }, - }, - } = ctx; + const { client: clusterClient } = ctx.core.elasticsearch; - const { has_all_requested: hasAllPrivileges, cluster } = await client.callAsCurrentUser( - 'transport.request', - { - path: '/_security/user/_has_privileges', - method: 'POST', - body: { - cluster: APP_CLUSTER_REQUIRED_PRIVILEGES, - }, - } - ); + const { + body: { has_all_requested: hasAllPrivileges, cluster }, + } = await clusterClient.asCurrentUser.security.hasPrivileges({ + body: { cluster: APP_CLUSTER_REQUIRED_PRIVILEGES }, + }); if (!hasAllPrivileges) { privilegesResult.missingPrivileges.cluster = extractMissingPrivileges(cluster); diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/shared/index.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/shared/index.ts index be638975672270..40caae32cbb0f7 100644 --- a/x-pack/plugins/ingest_pipelines/server/routes/api/shared/index.ts +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/shared/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { isObjectWithKeys } from './is_object_with_keys'; +export { pipelineSchema } from './pipeline_schema'; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/shared/is_object_with_keys.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/shared/is_object_with_keys.ts deleted file mode 100644 index f25b07e1913297..00000000000000 --- a/x-pack/plugins/ingest_pipelines/server/routes/api/shared/is_object_with_keys.ts +++ /dev/null @@ -1,10 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const isObjectWithKeys = (value: unknown) => { - return typeof value === 'object' && !!value && Object.keys(value).length > 0; -}; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/pipeline_schema.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/shared/pipeline_schema.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/server/routes/api/pipeline_schema.ts rename to x-pack/plugins/ingest_pipelines/server/routes/api/shared/pipeline_schema.ts diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/simulate.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/simulate.ts index f02aa0a8d5ed6d..a1d0a4ec2e3d32 100644 --- a/x-pack/plugins/ingest_pipelines/server/routes/api/simulate.ts +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/simulate.ts @@ -4,12 +4,12 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { SimulatePipelineDocument } from '@elastic/elasticsearch/api/types'; import { schema } from '@kbn/config-schema'; import { API_BASE_PATH } from '../../../common/constants'; import { RouteDependencies } from '../../types'; -import { pipelineSchema } from './pipeline_schema'; +import { pipelineSchema } from './shared'; const bodySchema = schema.object({ pipeline: schema.object(pipelineSchema), @@ -20,7 +20,7 @@ const bodySchema = schema.object({ export const registerSimulateRoute = ({ router, license, - lib: { isEsError }, + lib: { handleEsError }, }: RouteDependencies): void => { router.post( { @@ -30,29 +30,22 @@ export const registerSimulateRoute = ({ }, }, license.guardApiRoute(async (ctx, req, res) => { - const { callAsCurrentUser } = ctx.core.elasticsearch.legacy.client; + const { client: clusterClient } = ctx.core.elasticsearch; const { pipeline, documents, verbose } = req.body; try { - const response = await callAsCurrentUser('ingest.simulate', { + const { body: response } = await clusterClient.asCurrentUser.ingest.simulate({ verbose, body: { pipeline, - docs: documents, + docs: documents as SimulatePipelineDocument[], }, }); return res.ok({ body: response }); } catch (error) { - if (isEsError(error)) { - return res.customError({ - statusCode: error.statusCode, - body: error, - }); - } - - throw error; + return handleEsError({ error, response: res }); } }) ); diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/update.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/update.ts index 8776aace5ad789..0d3e2a37795273 100644 --- a/x-pack/plugins/ingest_pipelines/server/routes/api/update.ts +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/update.ts @@ -9,8 +9,7 @@ import { schema } from '@kbn/config-schema'; import { API_BASE_PATH } from '../../../common/constants'; import { RouteDependencies } from '../../types'; -import { pipelineSchema } from './pipeline_schema'; -import { isObjectWithKeys } from './shared'; +import { pipelineSchema } from './shared'; const bodySchema = schema.object(pipelineSchema); @@ -21,7 +20,7 @@ const paramsSchema = schema.object({ export const registerUpdateRoute = ({ router, license, - lib: { isEsError }, + lib: { handleEsError }, }: RouteDependencies): void => { router.put( { @@ -32,16 +31,16 @@ export const registerUpdateRoute = ({ }, }, license.guardApiRoute(async (ctx, req, res) => { - const { callAsCurrentUser } = ctx.core.elasticsearch.legacy.client; + const { client: clusterClient } = ctx.core.elasticsearch; const { name } = req.params; // eslint-disable-next-line @typescript-eslint/naming-convention const { description, processors, version, on_failure } = req.body; try { // Verify pipeline exists; ES will throw 404 if it doesn't - await callAsCurrentUser('ingest.getPipeline', { id: name }); + await clusterClient.asCurrentUser.ingest.getPipeline({ id: name }); - const response = await callAsCurrentUser('ingest.putPipeline', { + const { body: response } = await clusterClient.asCurrentUser.ingest.putPipeline({ id: name, body: { description, @@ -53,19 +52,7 @@ export const registerUpdateRoute = ({ return res.ok({ body: response }); } catch (error) { - if (isEsError(error)) { - return res.customError({ - statusCode: error.statusCode, - body: isObjectWithKeys(error.body) - ? { - message: error.message, - attributes: error.body, - } - : error, - }); - } - - throw error; + return handleEsError({ error, response: res }); } }) ); diff --git a/x-pack/plugins/ingest_pipelines/server/shared_imports.ts b/x-pack/plugins/ingest_pipelines/server/shared_imports.ts index df9b3dd53cc1f7..7f55d189457c70 100644 --- a/x-pack/plugins/ingest_pipelines/server/shared_imports.ts +++ b/x-pack/plugins/ingest_pipelines/server/shared_imports.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { isEsError } from '../../../../src/plugins/es_ui_shared/server'; +export { handleEsError } from '../../../../src/plugins/es_ui_shared/server'; diff --git a/x-pack/plugins/ingest_pipelines/server/types.ts b/x-pack/plugins/ingest_pipelines/server/types.ts index fc702b40d169d1..912a0c88eef62a 100644 --- a/x-pack/plugins/ingest_pipelines/server/types.ts +++ b/x-pack/plugins/ingest_pipelines/server/types.ts @@ -10,7 +10,7 @@ import { LicensingPluginSetup } from '../../licensing/server'; import { SecurityPluginSetup } from '../../security/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { License } from './services'; -import { isEsError } from './shared_imports'; +import { handleEsError } from './shared_imports'; export interface Dependencies { security: SecurityPluginSetup; @@ -25,6 +25,6 @@ export interface RouteDependencies { isSecurityEnabled: () => boolean; }; lib: { - isEsError: typeof isEsError; + handleEsError: typeof handleEsError; }; } diff --git a/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts b/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts index 41d37cb798833c..2df2727ed869b2 100644 --- a/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts +++ b/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts @@ -204,7 +204,8 @@ export default function ({ getService }: FtrProviderContext) { expect(body).to.eql({ statusCode: 404, error: 'Not Found', - message: 'Not Found', + message: 'Response Error', + attributes: {}, }); }); }); @@ -339,24 +340,16 @@ export default function ({ getService }: FtrProviderContext) { { name: PIPELINE_DOES_NOT_EXIST, error: { - msg: '[resource_not_found_exception] pipeline [pipeline_does_not_exist] is missing', - path: '/_ingest/pipeline/pipeline_does_not_exist', - query: {}, - statusCode: 404, - response: JSON.stringify({ - error: { - root_cause: [ - { - type: 'resource_not_found_exception', - reason: 'pipeline [pipeline_does_not_exist] is missing', - }, - ], + root_cause: [ + { type: 'resource_not_found_exception', reason: 'pipeline [pipeline_does_not_exist] is missing', }, - status: 404, - }), + ], + type: 'resource_not_found_exception', + reason: 'pipeline [pipeline_does_not_exist] is missing', }, + status: 404, }, ], }); @@ -501,8 +494,9 @@ export default function ({ getService }: FtrProviderContext) { expect(body).to.eql({ error: 'Not Found', - message: 'Not Found', + message: 'Response Error', statusCode: 404, + attributes: {}, }); }); }); diff --git a/x-pack/test/api_integration/apis/management/ingest_pipelines/lib/elasticsearch.ts b/x-pack/test/api_integration/apis/management/ingest_pipelines/lib/elasticsearch.ts index ce11707dbe32b9..5a4459fced6248 100644 --- a/x-pack/test/api_integration/apis/management/ingest_pipelines/lib/elasticsearch.ts +++ b/x-pack/test/api_integration/apis/management/ingest_pipelines/lib/elasticsearch.ts @@ -30,17 +30,18 @@ interface Pipeline { export const registerEsHelpers = (getService: FtrProviderContext['getService']) => { let pipelinesCreated: string[] = []; - const es = getService('legacyEs'); + const es = getService('es'); const createPipeline = (pipeline: Pipeline, cachePipeline?: boolean) => { if (cachePipeline) { pipelinesCreated.push(pipeline.id); } - return es.ingest.putPipeline(pipeline); + return es.ingest.putPipeline(pipeline).then(({ body }) => body); }; - const deletePipeline = (pipelineId: string) => es.ingest.deletePipeline({ id: pipelineId }); + const deletePipeline = (pipelineId: string) => + es.ingest.deletePipeline({ id: pipelineId }).then(({ body }) => body); const cleanupPipelines = () => Promise.all(pipelinesCreated.map(deletePipeline)) @@ -53,11 +54,11 @@ export const registerEsHelpers = (getService: FtrProviderContext['getService']) }); const createIndex = (index: { index: string; id: string; body: object }) => { - return es.index(index); + return es.index(index).then(({ body }) => body); }; const deleteIndex = (indexName: string) => { - return es.indices.delete({ index: indexName }); + return es.indices.delete({ index: indexName }).then(({ body }) => body); }; return { diff --git a/x-pack/test/functional/apps/ingest_pipelines/ingest_pipelines.ts b/x-pack/test/functional/apps/ingest_pipelines/ingest_pipelines.ts index 2a51983a990bbc..3c0cdf4c8060c5 100644 --- a/x-pack/test/functional/apps/ingest_pipelines/ingest_pipelines.ts +++ b/x-pack/test/functional/apps/ingest_pipelines/ingest_pipelines.ts @@ -17,7 +17,7 @@ const PIPELINE = { export default ({ getPageObjects, getService }: FtrProviderContext) => { const pageObjects = getPageObjects(['common', 'ingestPipelines']); const log = getService('log'); - const es = getService('legacyEs'); + const es = getService('es'); describe('Ingest Pipelines', function () { this.tags('smoke'); From 1ec21a5d88e26314e6da511a9677192601588dda Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Tue, 13 Apr 2021 09:53:54 +0100 Subject: [PATCH 13/90] wrap tests with retry (#96764) --- .../security_solution/timeline_details.ts | 69 ++++++++++--------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/x-pack/test/api_integration/apis/security_solution/timeline_details.ts b/x-pack/test/api_integration/apis/security_solution/timeline_details.ts index d653528fd47e26..61b75931c3c145 100644 --- a/x-pack/test/api_integration/apis/security_solution/timeline_details.ts +++ b/x-pack/test/api_integration/apis/security_solution/timeline_details.ts @@ -668,6 +668,7 @@ const EXPECTED_KPI_COUNTS = { }; export default function ({ getService }: FtrProviderContext) { + const retry = getService('retry'); const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); @@ -676,41 +677,45 @@ export default function ({ getService }: FtrProviderContext) { after(() => esArchiver.unload('filebeat/default')); it('Make sure that we get Event Details data', async () => { - const { - body: { data: detailsData }, - } = await supertest - .post('/internal/search/securitySolutionTimelineSearchStrategy/') - .set('kbn-xsrf', 'true') - .send({ - factoryQueryType: TimelineEventsQueries.details, - docValueFields: [], - indexName: INDEX_NAME, - inspect: false, - eventId: ID, - wait_for_completion_timeout: '10s', - }) - .expect(200); - expect(sortBy(detailsData, 'field')).to.eql(sortBy(EXPECTED_DATA, 'field')); + await retry.try(async () => { + const { + body: { data: detailsData }, + } = await supertest + .post('/internal/search/securitySolutionTimelineSearchStrategy/') + .set('kbn-xsrf', 'true') + .send({ + factoryQueryType: TimelineEventsQueries.details, + docValueFields: [], + indexName: INDEX_NAME, + inspect: false, + eventId: ID, + wait_for_completion_timeout: '10s', + }) + .expect(200); + expect(sortBy(detailsData, 'field')).to.eql(sortBy(EXPECTED_DATA, 'field')); + }); }); it('Make sure that we get kpi data', async () => { - const { - body: { destinationIpCount, hostCount, processCount, sourceIpCount, userCount }, - } = await supertest - .post('/internal/search/securitySolutionTimelineSearchStrategy/') - .set('kbn-xsrf', 'true') - .send({ - factoryQueryType: TimelineEventsQueries.kpi, - docValueFields: [], - indexName: INDEX_NAME, - inspect: false, - eventId: ID, - wait_for_completion_timeout: '10s', - }) - .expect(200); - expect({ destinationIpCount, hostCount, processCount, sourceIpCount, userCount }).to.eql( - EXPECTED_KPI_COUNTS - ); + await retry.try(async () => { + const { + body: { destinationIpCount, hostCount, processCount, sourceIpCount, userCount }, + } = await supertest + .post('/internal/search/securitySolutionTimelineSearchStrategy/') + .set('kbn-xsrf', 'true') + .send({ + factoryQueryType: TimelineEventsQueries.kpi, + docValueFields: [], + indexName: INDEX_NAME, + inspect: false, + eventId: ID, + wait_for_completion_timeout: '10s', + }) + .expect(200); + expect({ destinationIpCount, hostCount, processCount, sourceIpCount, userCount }).to.eql( + EXPECTED_KPI_COUNTS + ); + }); }); }); } From 3a7155eaa1d4cc379197d67f52c73f964c870262 Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Tue, 13 Apr 2021 09:58:26 +0100 Subject: [PATCH 14/90] retry users integration test (#96772) --- .../apis/security_solution/users.ts | 75 ++++++++++--------- 1 file changed, 39 insertions(+), 36 deletions(-) diff --git a/x-pack/test/api_integration/apis/security_solution/users.ts b/x-pack/test/api_integration/apis/security_solution/users.ts index 77b2dc4092b017..5afb2bba745a90 100644 --- a/x-pack/test/api_integration/apis/security_solution/users.ts +++ b/x-pack/test/api_integration/apis/security_solution/users.ts @@ -20,6 +20,7 @@ const TO = '3000-01-01T00:00:00.000Z'; const IP = '0.0.0.0'; export default function ({ getService }: FtrProviderContext) { + const retry = getService('retry'); const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); describe('Users', () => { @@ -28,42 +29,44 @@ export default function ({ getService }: FtrProviderContext) { after(() => esArchiver.unload('auditbeat/users')); it('Ensure data is returned from auditbeat', async () => { - const { body: users } = await supertest - .post('/internal/search/securitySolutionSearchStrategy/') - .set('kbn-xsrf', 'true') - .send({ - factoryQueryType: NetworkQueries.users, - sourceId: 'default', - timerange: { - interval: '12h', - to: TO, - from: FROM, - }, - defaultIndex: ['auditbeat-users'], - docValueFields: [], - ip: IP, - flowTarget: FlowTarget.destination, - sort: { field: NetworkUsersFields.name, direction: Direction.asc }, - pagination: { - activePage: 0, - cursorStart: 0, - fakePossibleCount: 30, - querySize: 10, - }, - inspect: false, - /* We need a very long timeout to avoid returning just partial data. - ** https://github.com/elastic/kibana/blob/master/x-pack/test/api_integration/apis/search/search.ts#L18 - */ - wait_for_completion_timeout: '10s', - }) - .expect(200); - expect(users.edges.length).to.be(1); - expect(users.totalCount).to.be(1); - expect(users.edges[0].node.user!.id).to.eql(['0']); - expect(users.edges[0].node.user!.name).to.be('root'); - expect(users.edges[0].node.user!.groupId).to.eql(['0']); - expect(users.edges[0].node.user!.groupName).to.eql(['root']); - expect(users.edges[0].node.user!.count).to.be(1); + await retry.try(async () => { + const { body: users } = await supertest + .post('/internal/search/securitySolutionSearchStrategy/') + .set('kbn-xsrf', 'true') + .send({ + factoryQueryType: NetworkQueries.users, + sourceId: 'default', + timerange: { + interval: '12h', + to: TO, + from: FROM, + }, + defaultIndex: ['auditbeat-users'], + docValueFields: [], + ip: IP, + flowTarget: FlowTarget.destination, + sort: { field: NetworkUsersFields.name, direction: Direction.asc }, + pagination: { + activePage: 0, + cursorStart: 0, + fakePossibleCount: 30, + querySize: 10, + }, + inspect: false, + /* We need a very long timeout to avoid returning just partial data. + ** https://github.com/elastic/kibana/blob/master/x-pack/test/api_integration/apis/search/search.ts#L18 + */ + wait_for_completion_timeout: '10s', + }) + .expect(200); + expect(users.edges.length).to.be(1); + expect(users.totalCount).to.be(1); + expect(users.edges[0].node.user!.id).to.eql(['0']); + expect(users.edges[0].node.user!.name).to.be('root'); + expect(users.edges[0].node.user!.groupId).to.eql(['0']); + expect(users.edges[0].node.user!.groupName).to.eql(['root']); + expect(users.edges[0].node.user!.count).to.be(1); + }); }); }); }); From 69f013e2fb64544bc9d16d3fe9f4ec6c14ed9c11 Mon Sep 17 00:00:00 2001 From: Thom Heymann <190132+thomheymann@users.noreply.github.com> Date: Tue, 13 Apr 2021 12:21:11 +0100 Subject: [PATCH 15/90] Added ability to create API keys (#92610) * Added ability to create API keys * Remove hard coded colours * Added unit tests * Fix linting errors * Display full base64 encoded API key * Fix linting errors * Fix more linting error and unit tests * Added suggestions from code review * fix unit tests * move code editor field into separate component * fixed tests * fixed test * Fixed functional tests * replaced theme hook with eui import * Revert to manual theme detection * added storybook * Additional unit and functional tests * Added suggestions from code review * Remove unused translations * Updated docs and added detailed error description * Removed unused messages * Updated unit test Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Larry Gregory --- .../api-keys/images/api-key-invalidate.png | Bin 129223 -> 0 bytes .../security/api-keys/images/api-keys.png | Bin 111824 -> 158901 bytes .../api-keys/images/create-api-key.png | Bin 0 -> 377920 bytes docs/user/security/api-keys/index.asciidoc | 57 +- .../__snapshots__/code_editor.test.tsx.snap | 16 +- .../code_editor/code_editor.stories.tsx | 19 + .../public/code_editor/code_editor.test.tsx | 4 +- .../public/code_editor/code_editor.tsx | 44 +- .../public/code_editor/editor_theme.ts | 9 +- .../kibana_react/public/code_editor/index.tsx | 58 +- .../plugins/security/common/model/api_key.ts | 4 + x-pack/plugins/security/common/model/index.ts | 2 +- .../security/public/components/breadcrumb.tsx | 20 +- .../public/components/confirm_modal.tsx | 84 --- .../public/components/token_field.tsx | 140 +++++ .../public/components/use_initial_focus.ts | 38 ++ .../api_keys/api_keys_api_client.mock.ts | 1 + .../api_keys/api_keys_api_client.test.ts | 16 + .../api_keys/api_keys_api_client.ts | 27 +- .../api_keys_grid_page.test.tsx.snap | 243 -------- .../api_keys_grid/api_keys_empty_prompt.tsx | 148 +++++ .../api_keys_grid/api_keys_grid_page.test.tsx | 368 +++++++----- .../api_keys_grid/api_keys_grid_page.tsx | 526 +++++++++++------- .../api_keys_grid/create_api_key_flyout.tsx | 378 +++++++++++++ .../empty_prompt/empty_prompt.tsx | 76 --- .../api_keys_grid/empty_prompt/index.ts | 8 - .../invalidate_provider/index.ts | 2 +- .../invalidate_provider.tsx | 33 +- .../api_keys/api_keys_management_app.test.tsx | 65 ++- .../api_keys/api_keys_management_app.tsx | 88 ++- .../management/management_service.test.ts | 2 +- .../public/management/management_service.ts | 2 +- .../edit_user/change_password_flyout.tsx | 5 + .../users/edit_user/confirm_delete_users.tsx | 18 +- .../users/edit_user/confirm_disable_users.tsx | 20 +- .../users/edit_user/confirm_enable_users.tsx | 16 +- .../management/users/users_management_app.tsx | 11 +- .../authentication/api_keys/api_keys.ts | 3 + .../server/routes/api_keys/create.test.ts | 133 +++++ .../security/server/routes/api_keys/create.ts | 49 ++ .../security/server/routes/api_keys/index.ts | 2 + .../translations/translations/ja-JP.json | 23 - .../translations/translations/zh-CN.json | 24 - .../api_integration/apis/security/api_keys.ts | 22 + .../functional/apps/api_keys/home_page.ts | 15 +- 45 files changed, 1869 insertions(+), 950 deletions(-) delete mode 100755 docs/user/security/api-keys/images/api-key-invalidate.png mode change 100755 => 100644 docs/user/security/api-keys/images/api-keys.png create mode 100644 docs/user/security/api-keys/images/create-api-key.png delete mode 100644 x-pack/plugins/security/public/components/confirm_modal.tsx create mode 100644 x-pack/plugins/security/public/components/token_field.tsx create mode 100644 x-pack/plugins/security/public/components/use_initial_focus.ts delete mode 100644 x-pack/plugins/security/public/management/api_keys/api_keys_grid/__snapshots__/api_keys_grid_page.test.tsx.snap create mode 100644 x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_empty_prompt.tsx create mode 100644 x-pack/plugins/security/public/management/api_keys/api_keys_grid/create_api_key_flyout.tsx delete mode 100644 x-pack/plugins/security/public/management/api_keys/api_keys_grid/empty_prompt/empty_prompt.tsx delete mode 100644 x-pack/plugins/security/public/management/api_keys/api_keys_grid/empty_prompt/index.ts create mode 100644 x-pack/plugins/security/server/routes/api_keys/create.test.ts create mode 100644 x-pack/plugins/security/server/routes/api_keys/create.ts diff --git a/docs/user/security/api-keys/images/api-key-invalidate.png b/docs/user/security/api-keys/images/api-key-invalidate.png deleted file mode 100755 index c925679ab24bc64565b780203a05bf0d1183ee24..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 129223 zcmb5Wc{rPC`v%;ZPOHo4u3CyNQ(7(B+G=mBN~cuST1!%0EJc(cA|hRx(o)shMNvDk zhX~SA)Rs_75R%#wM1)2XMC5zS`+K{*Gv9H1U;gliJoob4*LGg#b=`S(?W(c(7O5@k z)~yr2Y;y7Xx^-ftb?esaZQ2O@XV<1Pmh09%San4-6{LbA6U%nUWq$240$qA;f zE^pGRf)g-IXznSf3%z_li66`spd#PMX+GI;Vbh_X{+QhUQ_Z!mrM+Ek!-fqmBlS<| zD7)i7J@NMdDa%ON{8OC67BO$$-2M9XtEsc|p+@NcPPXaLi(-y$;_V0q4u{)A+WhlU zz(i`qi|GnFr2W{Ge{LjWR@Zjkk%P9>RrtsHpL4E*e1HJsvJA^_TM(XGXQOTD}|w${O@x#cEHim(aye5sF{Vu&VN;r6B5nl@yW?c@+VHT z1)x~ES)^%PtF=lK#9lh zzbVvN9TL>ncV!g*|8_tsU5m+#X>EfHhOR--^MB~{C$#u-|G&LkyCU!70B@K4jZX0jb0DEkaOqw78AOv!i9x@#O``A_W_ zhb0BF_`fTqmN4cO2Fmwu_O8NJxBm-N_)aj%V5B6x5E2MKdJUfp(+{jecx z{93~CqyN~`SFlWPD1XM_js|4-b}WW^ug`O6LI`s|)=-rnI2-B(c#O)W)KJA_Nw!kS zQ|QFGmYtN1sL_v`*8@KL|2}R9td8j0fSkkmv568Ml}Qyz0SQ8}?FxH3rPBj3WS^8Z zKbTcqJmWMSYU^5xVjFN41xYIbfGm;R#h51qv4_Pv6|E-RV>gw>nU(g z;pp~XM~%DTQfR?b(_R5p0e>>Sk9rV3rqCWay540Vk+mi~O8oG(VMdU+tuDEkWn^TO zT^WecSs(dYMl}gLTf*XnwMCYSeILHAU>I?w{j;?L%Z+Z_+A3Wjs2RGVCdG|lWZxxn zJ|3c+XdgM!+};#8@A%a3hhZ0G7+qd9+7{qp7S5>t;0G%4EwI*f7*f?3`vX~l6-5>V#+fpNEbUnRu zBm)~r-C*wTSVPv?}9A zyHRr{!U4ld(fUzC(l+D2;f5bxU`JwSs8`Y15lK?7Ke3qg!_u8k1_1s2FIxCf9nApU zR?^wkae82202>IpcJ17cNAkBEj+6jFf;jclcI9XOe`xrAtjzbl>_|Mt&`s4;xurwm za5%?*RyTSwLkg>>qeB^=m^l5@Iq3esD)eUmexAukAnKg))cL;5K}Wzq+D=?ugC>6m z%q#zxE<;OewV3sj)Gn+^4ILL}{L>+z0X-$nM=$_q`m@Gw$`8a`9D}0snE>wl!GhD) zOd%(Qu@J@fQJnOeJJ|9+6D4tK>i@>^TeogKj!xYcu~$U{N^h@7(1D?E zl(}>qS1GHi>Ib`McQNS`6G`II8bOq|J;`T%(ytcbeJ2!A2^@yXDM}?fhp^t657F84 z$YHhGpJ`@f)OcKpvp7FSMSyO&J9!S3KU3ZQO+fcUHx~tl45^?!>Y&;r^T%dM8VQJ5 zMCS@YBq-|;t&W`J%pHkJ0(_gL{p{=1?B%wCE10Tj`|fHlMA@y0R(pw@BxL^(xXIcU z?A;V8sH67vst{K0Dc3AsM=i-1i0WUze%%9ySD*jF$n9=7)1;y221Ik&P|sh!E93iw z3DbRRwq+hua+{!PR#c;r7KrHZ*Gx=K-jH2BWT}J};|y(8Nsu^U6Mwt;9#xhQH;RL7 zidRQO;zSRPcj+_Xl4_@8P|5W-zPp{X>;Ko#i_F8e%boVF6{45MQc1a1?^!*#2$}0O zm{$=D*A!?Su3*aAn*OQ{Q(lyhVOniih)}@|nI@|ZypD~v5mXbxMsZshQt7KID5Ua; zss?Uhi)!#k&19c%r};qD$}NQfO7F@6>$(y-K{nTlJ9XzeGmb~}Mj6bP50qrga-NDs zFr>Dueh8COr$tIx-mR0Je=)UUWXqvDu;X1*5nV$*bQp`}nDoE~&k}JcZE?2So+U47r0) zW2`5KA;mceo|a_1O(7-Wh;d|(`;xyBT7l3UO^ej91tDt*1qLbpXe7;AB9Sycx-_eY zXvedWFN=vP_k3~gw7QIeI;5DZ)PL7m_vLrV!D5z#!pbNRzxHEmWjhk}nuq*J!jPSO zv_STZM_)k&ClYUfm{uVr-ffE`yDxDqbS1^RsAab7C%8`UT9&e^N8ilrCWLo0^_EQ0x<<tb9!pF+}|4GP8)iN*&Z%gojXydqX#FeoSGpt$T=3BxJmJZCIJ= z-U#(4!lwF$w~=HWjk3#TU|=17x!} zEE~(Bcm+sECXF>SLG5I5aSCk4#15p*Hey<&PE^v)w8xjw61%yrda1)Gvy?3i35n*8WGzm68L8Gy zx$a#q(qQ_d0iz~j#(+ea+vSiWYEva8ZQiXwKIcULz5Pls!?i(wa}b z{Pw{J>sz$gkyp&nC4KsIYAqqj$@yz^DOd6MadmLWfI?clj_>#}OB)*(5vZ|GgQdNQ zDoSf2%&+SA?wIP$WdnE!{uEgRNif&vKEF}wcO$bo<#;2su!!Akev0e}v%Js+HXJE( ztf=lQDoppW=sL_9;-FMf+RU0KcWvTMV5^WKBE6Cl4Vhgt~N7;_lf<;BsHIdq= zu3i*M3iqWkZZ*1;v+^nW4CgMp&K(umziG2%;`T)33~ln;E38v12Vmh3+`+a10J3n; z89o(p6Bxor<8N%it`Vh?;RUL$9U7<=4sP|f{^H#2GNgcKUwaiPNt!6lvR!HmUW{(G z4EUG|#5;)y^_9gG*l^snm0p3ZYk-%GUQkbayjI%~LS*=6ODiJ{^pGwc>q!W%VdoJ(rNSPiJ5>!MV%} zE04DuxCHU0y~x~T9JIFiZsITN#gZgNyzS6lb^o-d6}nn_srm*Hx@8cJUfjDkn$Eywx;-_x#T& z=5Tf@E~n-0!=1mOxl~eL5Y7Jc$JO#qwh?nnUNh21Y*K8en+3Zlrs z6#OpnCkJ#U5S#M@>-?EE95xwbejrb{oYe8x&Lft@2+(dMb^{JaUzYa1Jb6>Zt^8d_ zEx3BG%8gr)s;RywTC>eaJ~A_s_3G8df*F*x2O(NZNf1eWRC%S}v_ zKX^8IW&3XV%!**W%3`QhRQkV1hGrTB|kN!UVE%kX2y8Fk>_jgK2cx!8G zd%g&+nzm2hrM*f~C%(6fg7+3ZxpkkUB8YDY>z1yrtqW%;?y2Xg{ecHlgFQv}L3#oI@ zv>v-yewX>0%`A&d6$@P;bZ#34#omfsuoBpC%=OJCU$XmZZ8Mv51t7z6F@A1+eR{b; z7_a3<8KWQGC8=cZ$(tQc;&pt_{>~wR>{Yg-Wj@R+8!$4@!SaCsPkef&HZ1>yV_vr= zV)=KSnJNTPkFS3-fK5&5WA+-)2Mc&CI)~`gel*T$MCHl``;JT}d;7$%TH;Q+G;2ps zeK(%Zg^~W@n+v$3zE`xxTP~P!lE#Zy%>m^twl~o$vtz82`$GW^~}-wkfnJ zYVXSLMJr78vVh@gK(&IGeDMYwQt^=CH zrx<8d$r8;Vc|wI5D(zhXjj+u;6amN_Nw1|gW3T8`K_n#pvc3i?5A;b1ua!#IwR=hS zU=Eb{b+y~PXo(FK~F@h-q`eZ+trKf!4NPg2qlng%wH=4&>VFCntlY~`E+3vNV;y}z0lD*#L{reBrS>A+IdP7uVl9*gbdw^H|L7ACr zHdZDg_;mTbh%WIPx*}lYfb^DETNxjIUB+e0GJ5F5!mnr zAD_kS?i+*AUKA~y70=h7VeVWV^Xq;TiY}U!-zx4v8|}8)ta;Wn-j1tFm+}=3G9iCf zm5fdibUk8E-e^tO`^~kv*34;>qLSQ#0ggIFl>wJ=51G7aVcXbhoo|!P9nu0K5`v95 zbuF;=6S3JT27uMM7dP~^C4#(MNC^&OOpQD9y89%e*m%*Q?n;_1ePwIXA1h9x)%7#M z!&BGE0fbfX5{WPsOX)DT48a=BRF8l8@R(k?=EBrwm?OvW4U#ILdx?&NJ*c4wlpqX< zI$m$s{?5+P@WZF>;IlSf6GK>Ay+yGMysfII&OtvCIJ06d%g+;BNa)%CS$_%P{`Buz zpX)0CdD!!6OnQiL43Se`e<7SvkvVufzt3y^@Mp$gT9b9V^`WvX&*AFc6+tg~_m-61 z3PQUvCi>pIW8)-8+3i-?Hq->IDB(qtMkW{Dpopdce%@swuQy=RSIlrwB9w^#B*Fs# z@jU=-ue>o#Q0N{TEN8te4)(_}o1RvWCvz0HpP=DkVCeX#=s$~Up$ugXPlqiQJ`xu{ zGo^ZcZwXhwIZ{Y;^qarCx%-!#Ij59~HrqroxuEutEGy=9%|BN-6Sf@>9zHfz)24Y+ zYZ_;01Ok!M{LN*r;~woHa@-=7&#VNF?FZ9e#dMt@EX*{c)g6QR?P; zLemRAoLqvg!|g+=xWJIrr*ZKP5PbgNP)M^LoeE?)+YOeh;Pe5885yEyYSA@nFJabj z44tp4t71Sy4FRcsN0N7~2T_~x=n1^3H#23Mc${cC$(!uJ2+8wXP0weUm}g1LTkQ}* z-xd!SB1o^Jl$5Dn`d$NrsG%E-WbZ8V)aX95U!^r5XNqc9vv8j&ms%P)3Jf4GJ*DJO zsotF>yrtnacyZ@<5vF}7OL*Gxx=YPK-iN%~n`hly2LX&p`71$}QzLWfZS^>vJ}@#4 zV<}>X+x+*J;?p7+nl#@qR{OTk0-NTAF>_t7uw+;iax%KPW6+;@FIYx`1YRZQZ!UsT zmn4(glnHs%^t5pR9d18LxbK{X6QBjVsr;rF(kJvVZ*QjIL?6P0&3cP-87^(Hh3-Y> z;v$i7aWfroa;iXpe#bgngn5aPHc;F0p@?ud>m>cVfU+qGop8)3j;@y-2--T`iJSlz z_4N-p#N|liv_AmZ$0v4;Amni{J@h0R3>|(!50jJ6nkm;0DEONi7`i`JxS44tGqq;^ zJbx=)Z(dgm$!Gqk84HC{{YnXH`4pYe{_5zJ8Ron6$gehqSGRGCmRibUv|D@A^dUpA z0Fxko6}mQvqSfbdrJ)%pJtY7+a%n@a$DJo(vLQoiKqyCy8D(BwaDec!Oc?#<<;||H z-+Y2v4q!zS5(=h&>{3riNRT%$NF9ZH721`2c0>%DZRWVp0tCbKNc?P}KL$S%`$Q{` zUB~|g`sMvYMKJ#K9uf^fNJts0Q9OF|XqGuGB&#LUY&|Cx?52%eHG1PjOFzt&biyAE zj=b#Nt_lWQS;-lMJi?8CKC!}oo6Z|)P+*wQa2~c79*VwZ*(MD@=~;S32y3;`_w#_`tdfSDV?B81S-BI^6C0x^W?dNGP-g|$L(4W7Jx@KPE z?ckeRV=Bxpxy0fCfVna{VI&E5DJ{*mT^f9m7D%1vsCYiC(Qa&P{M$*F5%p;(s)VhR z2fiSGdd22^U_ZG**zB=7fBtc3kNZABwSCQdvCpgO zxnul{nQ9G`smCdO>i+p;O>qKd)Nf927~S+~DrLWF)@L4D$4 zsBJ?}=K9CX6YjRfLPLK@ZZ>-pIY5yaC^xE{JozpVjadr?fcNNDQA0ziMlQq4-0br9 z+-@t~x^^(#2}T3otvubxiLv?}TzV&0i~UG`GjrTqMc5r+on7kQ?LtYLl@$T`X8zZb z?6x)BP9DBrCXxlAI9>vVRNgAMsBTuYptNMm$a+-2BDW z?Yn};&xJFZg@A$S0F;>hWPfg%Qat{`m@29EersdLtIyZF7g}RB46lVLotKutk(w|; z4MSo&xL%ylhhuK$<1>-gte(0e)}enjaOUj?LuSu#q4O8@@#;%Qa$coWdgMooR#qpFRHKMH#s7j5k>Q72twAiJjke z>2_+ZmI6>%TOF{TC5BkBtrffFuVUivR(gnTTsX}I7p*qGe)GmU=s+WLb#dlEU}A{XN5-DbO!(??-{jT;{SoJG(7<*}j!^f)>@YnzuuupDwF_euKVktluV zy~-y|?V{|-2Ij(}&N;yoY*Aq2#lu>?+eR=1hq_ytO0UII^Dh9%Pd;n7w$7C#%c+kL znN&F+u5vP1slp8qP$>I0`--3n_-HOc{-mK{w?_ycI8(*cY=2z@Q5VV><}Z^h`5^+5 z_8|Ye_fmTelu@>Cqo7k$&F5?6uf!>-lJw*H3w2#*Yl4vC9knu!t3aHG1h59*%)(Fu3IZrwWw z|D@^J@77mv!-Gx&-wgd-IHhh zsSE-u60r*;h}2=Lm${O1xjT5@tmJ*}rtcN6nvK);vTM;`=4<}S;W4Gk;k#j5ip-NV zys=O1_);(`GBx4!ZI}An$I!OyFCTWPdiLLkPV|zG?s@7^0kO@^aBQe#Ry;C9pHO@b z_U{m~C5zv==I_Md{1 z!oR)IIul#B1}sp)=}NX+tBeB7@E36`SL0zqXmIst=+cx%4ziNBWH+{-^3zA#Is)z&cd^=)Y&r=E({8_lGSWpQcd zfyvV;r`OUbEJ%99&n6yKO$je%$$pznVFfQhv#1<8c>;S`haUR^ar7_ZF$!D9W>Cg z>xDx%8q!ppstZ1ksXuxUdaLu)Y~^qW>_sDe%}3i;Ks*4bXu(VyjG2=E&A&*DaWz$g zMb=jFW@5bpC^cLjel4@;S-de@#gXkUx$Vln_|h9-_OwoXLtO{=0A<07856bUI{+WG z)|2r|BWL=6esF&QsJ01c8XMT7WN+oZX>K_^(u1!ZUj6K`)29yvaJgHFQmn=kAfp!) z;XMa@%TS`#AO{}65PDfqlu!&}${X#E9{K6%^+4j?vPVKm8#i2oD$2`n+Q0{Bfj|np zB0-xasp{8%zq`cJm9hbI0jE6x6iIH$n=N?mq-Zlp(GU*l_K&5sDPwjd1QZ8+5};8u#g&CB)vne;sIo# z)3wu)UTtwc)d-bE+Wbu$k~d!fx=<2tpZT$?L9JW}Fdh_k4VkS?sF~`;8p?hf;N}U$ z2-^b&Yu#5#1qCN{?&vgN*NdADPwbXAJrf7^d7+dnV#^WFMCr|!=jNOxwtn0Gxs2UF z9hgHm@?-!-&x5}ustT7R!DrSg4M4k_XeFR$pUv;z7Lv*Y93jx@0@Sa84l%l}m=vH^ zVI7)Bj1%%PII3JJkk@)^03i9+#J4eTEw-!`TQ9MSSxZYxdq@wvGiz#w3Y_pnfHh2d z^=elcoa3Sj8RF9;-#ao|-=8q<-*9yh;izv#zy>&A145gX03RurN!}@Pj;p}?9TBP746zG(Sl>n z3fhx#96O8e%k|WsL>ohWSDYz83I1vfo?T05tzWVG3lC*bq$50^0i;h9eRvK1D$*T4hI8>tRzw<6P>t}QgAUY&#T~b?{>fa^n zhutFpfe)R#`3wkAgt+ze_YZ+)oHRqMTL{oKWwYbknrmZ$ejbf~Y47-+W&>*YZNR#N zR%t-jA=GoN5hCuVCL!IC4tPkt3L0n=1R4o{7@_NWfdRB%l575_nPpQMM|an=#P)Vm z3c$I3C&Sk5NK{P=#Qd{MBw-CZe#pY2wtA%R>XW%XLAsEucBK#26;SiqlU7o?6Ju>@^ylYP6Uv~ zN_Px$r8|Ji=D@+f_xJ^-Ff)o{u2qKby^f1ZGE1sVtXVN~X>3&oMB3o4=gq(jXBE38 zAgSnDf#)G8H<9b|`8Cb7?>U@TFE!maeu?qKA)N`jQW*&S!%=K2PM& zG!khs;o)1#yeL-%IRA&d$*z6@dBflhC?m;dEIVF{v$tRRw|j;HR(*P=OV=w2UehCy z#$6!pR|KUvc>{4&6{vIM^5(;$4$RA?Ca*k==ormB(a~h)kmnf5J5fk_`t(XiqzwME zKBItE+)~#RTS%$(>M+}p>jnl_hGaOIUx~$(HZLSxU5&FVmX6*W`D|No8S^1>nt*DU&Vp4OB(6fw?>QiR6xFEPS@U=G}X@+Xn zwUw}4Y+G!6W}8jI%A-ag?{$A%3vr)11OP}WM9t(1Z3lPi)fbQ1qLoCk8vf~SftQxw z$Ftp;&F8vt2$U*a(6Z`X-tEpH-r{)BPpaOZJjQ`nuF@l9!3lEm=C{Q*{4EuNPMSCc z87b zbb%a8Tq-@HMGiZ8$G|p;6ci8WLGL{r^e`^VDI3Il#Kw#cbD_V#k*OFq*MUh;q-E;M z;cXh-736bxYBfr$Kfu-6*r@RXII1Md{AxaL*i1mIH6zs-wokIKZL?d$zng)d7NJs%fcsYOA!PL2Ck{jh8^eW9K%IeY=G+72oknvPW4x^ zAMDqIlp@hvl(nxqUobG>N-w?`md7m_6}+T1!h8~hm>#|Z9B!21D7LJZ$?lG2RSzUB zNwp?cNUw0{r4ksKd%Fpjr^FqMxD*? z&Q5eu(w48m*8Lww3h@R!U)06h(Ek9We4mxY%@p-z=ZHvp;r7A!=QCRBpx-F9uBlU; z-usA=Jf~(fmSX)B&6Z#B6czY~&B_sf@2jvprs@D1Gh-3Sz`4aGJrM8AQpb0zl3ft-V)S!32rwbl6|{Q}3@zTag| z_bp88lv87GNKQ|pB|Fr;>u7_RRG^dfv?}8wtK^1+1R2kja+4Dg!@N4eQUezH%oLLN z`mh3Z%1bjAfh`O?JCBU9t63Cb3^nUSXanyco9yv1O>TEe;A(JsEKXj8XD0XY{<@COd z<_QX`q9gadwXm}??F-3|wwDSIC#NF2n(H7V>37A?1cg=;{mWuOmZ>_6iPI~6FT$4b zM5<50h2@o@3kb{StKiXcOvFS!>qStI*Kl!WQqJCSiNQld|MA0s6xVXuXt&4~A`#oj zdGzSla7M2MBal{fsXyYnP%NsTSQsZ#Jq!afkLm;#e+*V1 zr#!)&EYy+6&);H!DOaCJ1}JV`2n5N z&JOo3{?ef$t&zfXK=mCNsdew!si}QY5#S0h$#lY=5K_JR@5C0`-PPGtAy+$GP{_je z`2Di2kt5o+hlE1WFYfK<5U>fC6y2X6LpOUagg~d{b&w2^ntJytv4?-&upK7rbnPZV z`t=fSE9;nnICto9c zJS$l??c@~B_=>12wgdVcv2uN0i~S<{=u#}u2jLpw&kHC`VaNr4O;ED3IcleCYjFln zz0NWi+-tC!>>iho;iWe!sEk_b8tAEshi+-Kuav7?9@=}7D$J>sxqNWuc2Hqul!rm5 zhPR~3UmjbwOThAg(-XbY>K4c<6)n`JO&#xPnG7G-!fiF5rR*VzPF&L!3DW|v&JP(x zc@OPMO`O`gyQRgu)9aKh*2IIgw2kWAcfoY35iFT3KR8+)1YqJy_`! z74g^9XXxAgXKoc#M#g5Hbj&??YNQ$5eo^Hpb-5qUevqK*UAOJ1#9F== z0jhrJJXQL}4SSou=Qy75-Sz81pW4xt@Aa=5=~sH6O_UQb zz-1i|4$%@-YDkNBMMW$#>BJ~K_^=hwW?nNvi%fCb>Xlnbvrt7~Dp$iIv62rfQuD4^ z+S}XT2KvXs+=wJOd9w<35tLtKDmqUaWGJv7SHIMC{;Cq7+yfgkH1}|GK!$X5{V%B1wqj6{z9FmR3|-K_ znykxM0kMTIQq?+?jT^Sx`v0Dn1Zqgfj~!j|X|pN73U0Gk8(46U@%U#vc6x`a#Q_YESi34$wC#NafEv09|RbwyjJ;FFTwcnZ0BAbRWa@B1J+m;AdO@)%Re zh5T%C*C$y)Ez*p!ns%gVfc?R{*1sfhs#S`{*7mM_vB1-;b+Ki`wdHc>>@DY4a$umY zo*f2e9mRy53o9eE5HPDGDZI{rehJD7>J#)bI@ueLl5W~D^HjJ(;MJZ|viFc@NW5-` z4oSSCD4+eY=22plWNj=@Shv2eB-QKFoDXl~5hQp|kf;ZKG!W8Lm$B1%fx3XqWq179 zwm^KQS}Qb(x?%Tv8P*(KdWOBUoN~Igj`q;dIDuB`G5`hnZwn+G_iKm?ksM%;gn$u@ z=f~fK7#7;$`p{SE=IXNjj@Zm-(#Sl$DUPSv^6E)FzfapT^yv5&9^Q=N{R(F4Nxja0 zB-qKW467?sPY;jHrJ?P~AG*w6ksjWzz2Rbl2&#T1T@!gDJ- z{E|IzQguT@NZvFII?ArG=IK&<1ztHL&Hk$Q@@QLLIqH(NOa%>Y>`M3T?BsVl4g7*_ zVCxe>&;)*hF^ZotHIky^7b2q@{JcfQG1-@P6=)Mbc4=@Haz`4;@JNNyx|Wr!lg2EW z=9b6>X|>N3F|IUIT1Vnj9a3;Yh`BK;LkJR2c|5#uW^ntEM_f=dxO(A6ADqBeYkkH; zuteIBbPk8LU|BJ&sjf&0WLJ*n^)fB>FA3fXQG3|IiPKd*B}K~1U1RbC+_}3VXI_Z| zb~cAs!Ijp9E<5U6kHTUQWFjYFy{qew@?Z}_Oh)ZS+ow450Yt)fPb;Vx=Cq5S29Z^4^N>2cg+u);uY9FgFJ|rl|JU;_$ zlxi4u0z65nMHGQe{feg0zya)V+aQ8klxYay!>mPEFJfdLVOv zM*qOiTbNY+cpzLd$?v?~)00A>v62o2r(kK(m0=bpOC7t_s0g#L6#S)(6(eVauCpC# zyk@px$BcXtCUBNM)YI5>5Q(*>B04qx911Dn4tRetQT1K;jWZjT8^KTdOg=(TMewZ9WwwnQBrXgET>Isd#V`dWT?RUVtll~eodNr?_p3-gO$XiMf@%Wuw|WGhdTVe^g*DV8fF zY1_UtiX#Uy#%@l|`OwW!1|AXyQ6FUu+9`46-Z<#P)q!l$Tt6R7ddPE$v5GBnWTE8V zL2KM_iLUm|r8=lVWG5xEgf-AaDI%D|yB|fGRT5*OBwm=Lk}XqCXiGucwIlt?uKN01qiHH$+CU%= zeU(7k$n0MJU1>02J~f;3l-C@?m>xbJwH^jq^||Mgc{rO_8>P>B?Mn&iDUC_c@jjo4 ztPe53fV{nH?hDGPNeI#M)gCic^H{2-rvDVoERS6Yq(}YInnS)^yMA)S(7D-RD#GS?EO5t?>6g~AG!ebe@Eq~0;{*ybHie=i2y|zAIy}5B!WSmcQS#CFJ zZLVVPd(oK=HMy_ncJXb%Lk@HOxW2pwdbB#IDHLLDVkCY{!&p&+UYvU=W zlQ2o7L;2J#jg4D=>y;q&ifbc?Ef=0{IXAu6+S&AJ?ecrk(3bw+kt2od2 z_S4Cm26qxZv6b)V=a(M7pLSWYY4~X_FNI*rtVf4iVcarDFHs_EJ+sl_5XGLnx7k~- zPSnaJsSmJ1Pu0vnlXO40_aw=;`I zU_l~e0BEx&2U&|@!zUFlZC?&5XSG^dd~kZ--RN3L?OEcxQpc^DhZM>|rCFpG66b2hOq*Sc>KbN3dUCIT z37zR%#~~uC4_2vdd(k>PlL7yn(TCx(*mb&r@4yA*I*Rv~O(MIgo{FMjN%iT6TfVa4 z6vwCW0PE$CzI5=743){O>S5-cQk)JY!>_S@mJ3W;ksl>U!*Clx6*ap$(*O6W+zpN+ zSY@`>83P(-YYL-BJ)ct^Znw+^f{FFO(ImjTHaghO^_67)YBUpUgKC7PY;^_t??~i9 z)FVx|$)=Z^W3mTs+cPZ~(P*mH&G(%aysBPoNu#i9x2JJg1{94=P|9lm)5+9FGp9mS zQ+Z=d2BF?%c0%zM{!tpypy5)m>(+WH=ffHWC|AnE11ncQn~6=|>xE@tVa>|O8U0S! zmlkMRY(j#bLfU$Y1nG9Op96k-ZojtQM?0OoP>Ptr0`bHR>eA7w!V2obl4D%yrBaLe z>y&eBYsTS1OFkd4XNc5iaXCyejVegCkA=)#*Q}&Qp;PrdK=uN!;=oyAp_ewVoZ&mX z4S6E0h9t38R zYhF`S*KU4{P*zYL!wR-xV6e%J5Iib-o$f{1(;Bgp3?#t=;r`?ia4D5SQw>TT4C^bPyzFD2 z(sN-AimxvHNk}SR^HEd=TM8N~CEC(wAIZF8tT=NkJ!4lS-@EaT23iR+XE zsq_Qf9`Z8dT#96QEm!ACV-R1MA%sNmk#dfuA>f>bAw8O&Z5IKbbkVQj+MLc=J6&?! zJL&Tk;CqCnRsrU70F;z+$9r7+z#>S;EeM`EkfONP*(lcr70A;{iHrS9-E9OJawNRK;~<4k+I6oA33%Kb6jW_I%&HmUv_QCX|X)BqMQe?U#lFo@&f#WyhoRd+a@a)hpJ|8MJsRFh`;1F z|4CdySF0l(9a>^_$TH=P)IDp-++m|e!?4IjL!0Rffpd>wfLJ2vQu1WLE0$XxGLr0H z;7je*=y!UjY)cl;9DNrWsT2jSu?!RZ)n%}#xw*%6en`T;w=sXD$B2DH^0I$)uf%VE zj?*yjO((a(mO}_8IL(dNz6l*X6NgxmG9a1R@kSEWHeD2%nNwl zp)N#Mqcx%?GUfHPJNG{yM|%jB>`Ut@Ate!uQWGC!B7o~Wwx^=2&~~rUi@OCl5}t?aY*QnHR8;gnrjg3*;C` z=aO2TlNeI3554|?cv;a%LdCGZbG1IhD^*v$VYVU20Mk!6p|ALe8lfDkAcqQY*ci?j zj8Z>(!TpP4Mx?q#+0bk0508Ab{SHj-3m*>t+(PKOY;>Amn+9D<+moU2&QcdEC5_rgrIcB~8ss|(T5|9bv@)$lNaH|X49?b#Jl z^*%CwCk1R(9H6-`a}eUC{(SJq99s;+!p!#Eh&R0zMQPRaa-)`o+|1iz>{10 ztq$)z3!Y3Jqye4i;nv!HYQbehtyvwg<`oaj9WQLj)J_} z{Ul0=sw?A8+k@}fv1cn-wb@p2HHKtwLW!Ld_ zUU*gy>`d z_6kZad2d+_`RryeJa%NW8)bvHTd$vqQY{xl^O2GV4L3scriV_S)o+dL^O@w+d(M?`HIiNS&w44iZj{0B}#D5GA8Jtq*H{p7rE& z$#`|6dPRgzC~MCj%Stk$5VH4eSeadMAjJ6QSEBI4Sd~Tt>+e$BgXOiW?7LnJI#d3> zb)0=glHZ$|v$d%DzU8ick^OtMIkzNbt>1S(O}cp+lLvHPS|i1N=i`g6i*L_~7^%Kr zxb<@NmnpZLsgWZDKHkacgNY=fnjh}u*lTp$so0Y#v`o1fxy2as(xx}~Rg2&0lM0oc zcZl&ZzKcznZMIEYic$mqta`uyB$`t7sE zZ0*6Ah>z+LB#xx=SDiUO3q9O}w~w6WMuQ>eS@g`lj(ycy=bNK=Dz@=G)x9;JFgg`~ z7lIHbv|Xu;ZT_Sqi*~3G`o|zc}uOy02NglBlIRQQZx{Z~5iI%JJ04z%|(v zJaPgyTRmo?*)#bF;rU1SRhPy*^*72v!2Q+fn_JPYL%6^}?`~n*N}TQK6`vrd>$Yv86cED;4T& zk&K1Ns0(k$($yM+d#ZEOy=&H6GURO^bQ?IuRncuhGLx$@$s0C=JT%qpe0O^yuOc9c zS(B&^#h`A#$(S59OoUIUxq)-rH|Q2 zR?B8UTD<0^PtBa-1MC{~VYtr;%l2Oi#w;mhGxAPADFkD~i;N!qMBM|G;moyS%~)%j zhit+TfFvh#n%=pCXW(V;dWa;JY{r<@QvCJkE>_HR^{}i+m_AkqUm1E^Z}k;c7rVR0 z9RplZseWieYf~IF=k{6VdFfCnatp#NhO%S%M*~#edr6%lzaq6?!|>7PI04M6T(fz{ zBRy_-X20nwGW*xBr9h=5=l8@jM^ZTNLN}51-yyFxj^5GUsP^kaZSUI*`i)hAcmSH| zk~i*tZK3M?e00cyQ?xD#Vf~0k(#Bp#iY8%M0;LRGf^>;?6+T53#H{lI3blABE}*ip7&`}@cHORw@@L# zuU^E|AgN~V@BSaQzCDoX{e8U4(S>x=D8lJpgl_J)aHJ9{X2PtfTxKrK+{a0w5-OG4 z=6)Nt#Ky)fgi~U!TMQ#H%-qI?+5FypzQ51sb3W&s_aFT6e(!a8p6B)4pXbFg`Mj8M zW-R&i`dWH;g29{bq=eB;v+KJm*VLHA!ew>VurF&kq_|)mRs;teAKE0h`;3{vD=pVj z42_UHxxTd11FM-JWr}4>b16ypRNPQ^?fPt|-9fz!_{VaMzKBcsdF3?Rb18i|wgpBhI+Ka{=#%?5*LsI$G$JP6~RdRF1H zXC5lorZ(lO?l1n9yYm#(ySwA9%F!oE_&76_%HkI}u2^+bQo<>U9OzQOo-5{U`5f&ya%r1ijPCzcxIpV;m8b$XKaO=&UwaO`rpop;udG3$|U=JFlHbnEcyL3d74uZ zj5{1HSyDW4{1^QnrB#NqTO=J+h{Y4Ks+HCPsj{i8p1@~qnyJAuX6F#f&};UGETK2a zIvjrD?fMF06B6ox5|%ylDW>O3n&gf)y|P1MwoU^6ikezRZ%4{ZLmAYUbsSeU1(Zo7 zNaFk3$rHs=^kRNw+X1Zl%^J!%cd)!HRd0rZ_K17%R2ZxNfqd8u8#f5p6a3_2>V2l5 z#6zKEf01tbV6+R0x`lnksP{Wb?|We=K6kV)aMP}XFBi7_0-XzNQUNVt^%9FVLztJN ze}QCyM=rO#5Ib{BTRzkTmZHC*&Uy`T9zFEYhEDtJnau+;L<1otVi-=najMX=`$+W+ z6r>R48vS}m9KZ9k@G_t?9unGu76Wb!p-ye_-EcM5rCa(IF3Q1=gses7BD3h_36nn< zJApDi-IBDlfrtXv;z8PX?Y)yao#CDJ3SriSVBE`_JuB0fj@)?Y74~eUPg5DMuRN(^ zH*zRMxjVC1zdu3)C0@82@VslrGo;~YZr8-| zqEYZZ_YdyvOSJC#!*6`5J!F*ioP`hR8RBV&kii53Ads5|U*(Yo2i*pUbgM1>uvd&E zzKOQtQyP6PM&o3vH)7nys)d*L8{Al+muR8i7*m+-mEsEu@U1 zW2=j`$PzJ%K|o1&Fs;aS>M`IKZmM#R3~Rq;@jh5dU+^|ReAZE}JZjOM*l~JQH0+a_ z^4+isJB!22H`GKJ*No}|=ggT?9MS59u3;&+*7Cx@OxxUx9s$3I#C9)5EvF8MUmvC3 z!x+$QEashOo!DsYd)Bu=yf%)&zHnJk9UVR?5QHl-p_e)DP*0=AWoBUraHZbbh@_;3JRJh0H|{eN@Zw+&=CkJ z!}}&#N3KBMrEWIRD!mU|Zy@p|+6=jpgTk8u6(ujS4xQATEtIrTahY^2m%QRrQ{~O* zjSL6O7!}!m!P*VsYwa`D+~IL55Ltj&Zp_rx)Z9w+3HC$gd!1z8InsR0 z08jqD^72D2KlmvqPwyLU*-l86G*tU;2BeT`pSo9m0)(&uRklEeY!oZG!25^Q& zKYFOOE{IY%Twat@&hpfE!$pq9s%zD(ye_jZ zT^bUxIMErZFPI{r&Ob~`bc~K0Cial7BVUteT7KL(0*Ks9m>k#kma=hj(XVM~u-E*s z?sC=%qo^n9?Qn_&D=lU^kOSfIj|YWsiDUP2-BoT+e6v(~E4BknU|O~MZOZ)zkm}q` z@%lS8&9A%>-YWbSQ6P{x=;FXWrH!vla1#WnT;)_u>ZZCL8f-Tn{FW22s+KVoA5jzBTu-u=AU4k<+e}_I;uC>hR>+jeVim!-+csueHrCe}vk3SDz~>8Dq{?ndJ% zTkJ@KMhHglI%3QR2YiL9um$H{uH@{~FSrnT(NAHjU_a=~lF&By(L}-1hDj8Bh*l3f)xnE- zX(*!v8_wRe4^!;Qw?SAWh7*()W+i6+!Mc_(Esq*qFwL$bxo5 zX%;5@B?0?AMBH#pY~B)3ar#VNk>u?eSM0MFp~gopiEc}|sN`VHEac7)p83H+B>UB| z&SD#oWV`o(Ya#(E>~~F#xrCa%!aGFXRQznPp411Ocg~mt%hD=TCvF3|PjNBv*M_t&2nr;3_wQOXsIp#~?Y9o~ zDa~T2-ErzE2*z!)oQ~qiozj@or{4Yg9-rn6coM+OthlPL!_jsO#LW0Z$c?LVS&y)) zn38R{LwfLvb*94ZuIZgO%RizvbH3Nc;27p!a%D##zup{)bm}r-0HR$ItKv}n5$|E` zAiZw5!rl=!;-YPS)R+0QNzSO=VcWJ8#T3}EoL|>dpS#;-FZI01=fzNhXSs3m7^e%2 zV%1B9sY52_&Y;bM(id@ad8QL=s4(S}ZN(JVwUQj9$)<#5{6G{_k-6JEplU9}b9cf3 zIVtwx*Y-r)#P7fPv+>Rszn0(Wh&lABDOqn>jc>*8-WXi-1EjvGiJx(I+m+S!PH}Gb zg00mte{2))w)aM67{v~n?js)WKZvi_?M!_ArteN3bkA4Rea^vBx8GQMfPkM>7zHjA zNAlYnDP_D`W@JzDrxe61Evr=Wm9&6QR*+Z6tx|PdJ{*P5QUq@af}y;VpI>eJ{g~I9M@V=KN7M1kTQlNa)4iyGqvOe95e9sCB^lVlScCP#nVz9 zMp_=-RMZWOq3qlvP1Dqp-AT)|zWg+XjZI*D%VMO64D>69*|(%A*ZbL-clf$DcmKP4*rN6K5(D4B?fpb!{YkRSoi~`eZIIh#f!Td1 zW5_=5An0iWh=MaK(7snmQeJ=I#t*3n(W#P_rKUdPuEK?O$eWU;%IjxxFJ8*FP=U_U zOIbcJ?386udnJ`FClJS#njAUq1S+Z)Aah5{qdi<|~cA#ho@ZU^u3wRn15< zdgxmEWU+=zXw?t;@DzEtMAPyt6%>5IfK`1jkzyUqyjkY2Y#7R-kEq*wG89Kv5{34pOYlP^f{m{+w$xGs_`N*l~<|B_)B_br#jMPV5 zRF46P1vr9Vz=@vMsntI{-M*bs~{ZO(aibJNf+6m^BqikH*S9h8VD z^Wqj7u~7l-iz$6Jzj6Tb-yR)t?$L}p>V8+)^O;-gA@%}$o5$?b#qY-2?YF8w42CsB zFz}SOC!yPEgz)&;<}88t;Oy#Yt})B=Rd?2ad-co?n&{KhwYjxPL*BRcXcy)b%85JM z5O!S0eRYk?8Wp*3wsKV+ptqYs_ zPGYxk)J8~3c+D#yos(aCB2{;H5wdq~T*=1Q+1WkGWtFHvWzOz9zQW6K8&z!sSe~+> ziy}@RkBW72?2plwykALt5D|>yaY`CdWh!<(EbL9%Y@xW=t*dX&88!vy`oTs*xT|G5 z@$05Smu=v;D^HJlp>Y~ZQpF#+Wdnw}^yRWmjLX5ej@GL48gBqvCi{ZOMN>Txvnk8z z(a!-TEzaKLy|_1}pXF)i5+(?%FI+}1H-0{UhrJbiDe!T7{>%{xgQ0zOT0Z(Oqca2q(;Xgfv6xdl@(%>YR=4CbLH$WXF{;6}H#q zjI7ix9Ld=EqQhSEjh3F9(7yLFHW zh#X4t%CR#Gjl3xQ`C#RacT<~#;%RKlZ3dRxX_`0whT3s-x5)f~f_RyAwARgy(hNx(Zw;sW`{D6Q+%lHEsxf8jC&{9)>mU+Q9;mfz|c{XJBp_<`-jIvH} z<-`L;$M%-Qkvh)H288k!xT}f#Pz`|d#1x$q;Th!E1!EePXKEA84s}rmI?A1j*)%y% zl0>1~;}j^PF%Sus5`WDy^E;*m~A?e zvMZ}HB7g|y=>+9n(g+hArUicO>?d0(b~%9rsj!t7nWd>RgyHs z;M@vH(-k7O=IRWizx^;C5|N*IJsP%tsBah|V6Tv==J=fkh~Oc|7fvqbY~#qb&}=`C6>Pjs zjm&Qi$oP%@z7(awAVdqfdqjAxTzK~CsS%RtDdir34L1y)-0jM7t_$N9NJMug=(Oye zoKv#Pk37`#p>JzT=bW=!vdg2)GjEL#T&#zHXKKe|f%d`q4Nvzp9bY^7dyn|kLG-E~ z=9}(~dzBcC$c#X4QgidP}F1ny=TEjUG(At3dey|&RJMHKg_rh%oFTyiOHLEFDrf)>Jui3uir|CT z_|$s3SNg4rJ7N6w{HXPyg;|@a%6my?qRt*e=FF|fdNw>WO0rltemwX8%eJzD_CkVj z2*~kQ#Btp>Y}ymY?UxvMJ8+dsoU+`?_k4$7b-r@=3Gb9@uQpk!-HZ3i>YQ&b0VB**sC9|XWu7&hf}x}4Nitto1&p%s+hV0m%W%0dB27pIvk&nqSK0v<=v>chOmGg z#^{9&CFCa*W5<<#!Gn8=5g*JT4M_CMVvTL0We;%v5v<^*tbLDrM|eVHL22i5#+_UR?d+pNjWqqvn4vXU!yp3^0~2IR`Y z1|)Pn_1%b5CX9v>o{Yf~&~KLBa&3~gO<0YPI!K+A($TV~x9Sel6D8`VAgw0hi=7sM znNxbU5Lv6^b7eSCWu)`%?@cL4xyFvxlF#xPCJ$%ZDPw(0M3y{!GzzcOi^lo~oB0fU z!QmrL9hB;|AAp>MyT0Tzn+GorYCEdbQ!k+&&u?4>^uF)I*`s$hXnL)kmhv=eqa=Ht zrVI0SmFED$v`{n_+qgZj-t zXUqf*YHWTWlUdOIMP1ecNTng-d9KW2v*;hAk=(I2o!Mj zgSe|baBx`gAUwJODFGK#F3A)w>RP&GCcH_=#SY3z%qB-LB`4PA=Q6kMo1Wr5=U3|5;9`UT-$N%G+}J`^Co*Fcc8w|8oNq_JgWe*rItA+n!A zzb|ayHOD~)-gow0F%)f?-rPkyLARO zHKDieWG2(F>0QYoMm?N%KwBhQr5YJ4q;H{>?RLyAr@Px9%On<0H4W{C;As!ZgSeNO;{JIc#wKr zghK^mM6dcGi}9E<-BGtC$|N^{6(`JkOh;B?l_R{>u826-{m7n7m!Q${ZeA%tQ*-1a z3?_LLq~vLJ4O&Ge?n~#adib^lCYs{5lQga_HHBC>goy^^7?QA=vEy$S3+9|pzHpFp zKB9Li)hz+#VVE~1@xDU2W}%)v@qH z`&?I0*HN?*`D+ZE$`QmMFQSo~+9lU5)-1{inwoC9b8-)KD0{!;r|@3(iUZO+-Mp_(MaWZEGE$on&za+^+Xn=va(9#Bek);v~?kMspS7;WSfS4FeQ%67-R zIQCoJUSJQ4gwaur$5iP|Qmg`;Ot^u5A^S#G8Ne`La~y!%M(+a)R@vwF_LowvFuY8f_GJv^D6xg#WsF|2l9#QmE^ zWwaAsvH1Lx1>eG^o($+1A32!3D8~DD**xhHJ|~upouzM0gb1XmN{d6AV;z-qha>i5 zYJyNR0qUsJ0nZ6Ay}DI{ml>bVwh&ABKR8;mJItU6$cU>2922&0tyuLjrFnQi{A<|` zB1wBWT*qf2-B~Vb-u|JI;R%|)0fUk^<`tmUF5SEfyn-Dg4cRVY@>m?o$b-BGu-bKT zEI;J5)1=|K>aCFeu-C_*okoY51>5Y9#3co^b^DW4M?!A-;oWP*JE~}sWTSwLQdVti z^S>`aQx^F-D{WBY9 z5Awm7Xh+s9SdYbxZ`hArklBe==1nFn%8F z4df*00f`goE4R6Sl;15G&B{W!`uQ$j6&6WeEogVX3zJWNFgP`|D0o%(yM~0s(ny_R zoBdHfan9L8bR$DGUTW3U5jMSQzbaY3FKa0=AmvHIk;dk0D5og@tU>yB4h9D?IH%*@ zW{xclP+H6n%!WhO%h1=Klwl*+&8aSNd?=NH&K^6e=cs2YqZ-bZ1{+4&HRNU5p`g{5 zYRR(SNYmqU&O&I)H722$k-OnLZ?CEEpoP&#{Wl77p-#c-N=D)Ptn;orgfDv>u5Gt_ zSUSaBJKMME({1k?AG4QQnfj2`ZOqG73CAn^*gn;e6J7Ee{Sg5nz-rFl4N0Ob z!0^pcgTkFLqkykLeP3PzMyKQt&9ijVWvOJxNEh2^PQNr$s?M4_I{Z>{l)rCB`-SX_ zvox{4yfGGjek4${K$eNaqL<{a7V9M@7)lBWbaXvp3k@%ztO*j>iWrW|O~$iR-EuJ| zW8Jj#M-ZlQZ}qR_kKy!tjXodKGcqUkL%ckAvqlt3$3FD*= z&9cw0Qmf_AhO1wzgID^8D`w}T;23y#*AErLpn8t}e%lM#zplIVkUH*eMrf>`$7S9w zxuV-?{hJ~ne7_db@T=iz+)rlz?z7M#vl#OQNkA~j?8j`{@7Q~G=wslKK9~0e8mN6Z zj&JKuCBpDw<>;-F4feP`*E@N2cOfMY%34aV=JM-?>29EdaG|8y!sbipQ$gbc5+wuI zkAW|Eb9bzsy9PUoZ7Sm1m}J!b=vvJoM!3(dVaar4NV}#>r3MQm5Au5IAq*sTDwS+F zPY%Qy2TJY$i*hAR4GZhO^>D7YLJeIK2gz=*5>yt`3>I;!wsUk}`^xB8RsNJb`he3m zMSY5_sO`-kauGN7Sx-s%BjC&gkp$yiLR~}|vxEu`F8k7w56G+0rF&ITw<&ZF_svjv zzp>$^-m9IXSi+elB)Zdig4C|5MT)Q2?LvAMZ&b9EsKMs+CXad1Y7mTvTHs2osU0CV zYD30mf+J8If1Gn}4(QSEc)55qM?*sjy6zZzlgc33E+zE)%Jo;NF$I0BiPbK7PUucT zV{f^KdigmUm=}i>a8SwMG$LdZIE^!_?b8P$F7}|YjKa)J9Te`zmy3xFX5bH=F!^WB zHi+4=MDLtxml!5=QSL>5bDx7ZiV8H|6O_sAdXmAQ zFN>|<=Yf?GAKGK-Zy~)Tj9G7tq^jn9PvwQ=nCrXo7ya8-xq-$dib#u-i}ZInjL^xe zo@%zJ9T!NSn#av78j;1BlP=`zchft%BtxHXYH}6uyBR~SBUb#>KCTa2WkBCF>0v-T z#~=GzCiPuj2eUc%JlnFaJu|^QvB977n+xNm>qG>x{ZRerK94b&J6T!t__9DJn$*d; zmShm%Tk1gR!)`}W@7G+N_CFEDSh}(m)FDs3^utswUN5oYDk8&-*vq0&||K^Mk7qVkP=0Sg2wrHMKbXML0|eAoOq zo+Q|ew(bRt5AfDq5aNLo1Vt-ih446ZdA(vt%Bb+JvHtW2w0fZQ|L^K@QLx8-EadRZ z0H7VYeTa4NQ0wwn`i_X{nIVer#{2G%UbExtRMviU8~4ct5Vx$j8zPOC@^k8T(~%K# z^%k&d)YheArR{w@&S0|xcLs<-69MDprZhoki?SR# zJpy-|hQkYYO}DnS8(F|CS}eg0yOg$!{z2I)tMz6f8s97*eb({8MZ_u)_o3#o;Y7}) z8N3*ySmD^;SLHqTm7ERRapCOc?DTe#P&wS;ru()Gp_zqB*`^ze)Z^TMB_#%-gyU=y zEAC6-dcKM?MHEN+dWMKcgeUl4W0v>4W$T$wYk_Z#XkFvios@ zDUEl_N92n-6a1E8J004gnFnX!2M3*o9<9N9t2>;(qp&TL2KS8J3?>%YN$x8KCBqIh z6pbzv+v0U1$nI-^`+2UyT77D9J@OY?F1u&`wzhpG1sDTfR2-EmOS*}c*E@U3n)@SBdBU;lfJ((mElop`l<$6nc}($1&s7}4Eu z5%wGB>n;VuHcEeFp0JT)Rwf@T5KF>Yk{!?@iTR27iYSiv>*#TEd!-oRPM?a=ko}KC zdc%enHsK>7misXkWyK(};H{$!NV&JVc^G|n~{rusRcpX)m%;)J;wx*`0-GA}+uR$+{ ze*$6P%%^w1fbHxYf?XgGi+`&q|9TJnncp2WM)GA^kNtHomrCK76Slb0(y#g5o{Wx; zi+}$mBzS0G;Zy!iuzd*bEw}Q|*OLJw|NiX>vW@rU%VRJ`=CA82{X`lk^%Eh$s{R4Y z-=3T|usNDUCsF|WAip3#L&g?H*7UvgMbT@`F}L~@0I-P|0gp5PK8(? z+TI(2MjQTZRx{@j5)qnmxj&WtH|&&tas&wCR)e5UDd*iV1!dXt>&H1_wmmw@vUS90y>X#<eE zSMs0dli5$e-3@HxrKZZO{JCWr@X4=6-z8}T?r-PDR8&+>8{kVyj{KE11V5=+7^p%N zF8rw_+t0yWkI;ejo`L|^)4XB-<6ixv*==ez&MM%00Q2bkrvuI~z?WWg_y=@eO^5%4 zleu}cWnp(|$e+5r{hWIKCkaW?fO7(5u#{rvg& zBQr#9-9q}`V0@&G$o&aOX^_en2k#6`z^TyxI@v?)+RL#lV0no_koc zI$T$?w7f@K=P#%Ys_u`wyR`95XORj*^{L+7Aihbh`(%qu9cm>&B_DoooE}8-2d5 zf@kJZlI-K#r4#?MF{n2abGdq9;Q_?S$&$$*+KV}%feA8W;2*H$yEJyRNv(`-KQh?W zIEI_&tNDcfbs?_IK-f<;U#fw}tMVbkG~V_4cqlFDH*T#a2?gI$CkB@PXTr6B5Za$EvUziM3rB$kA1kp*h;w*DHs#ut23|3k4BsceG-Ola%C*L z2&#OIBZUP{$TdZ6I7_{1@p=k?nj5i+y8c|KFpOm6Uzyi3oSAt74@H$EC=z+K6$Sm2 zuyvQ3$jGeh_BO-!Cu+57D+IHFajHA3+ln8TPzHYZ7=&Iw69CQBxgQCO_)tF>zW6>* zeJ4jHvEMBr!+6w3mb7z;Cz2+N)KSoRIkfafN{VAxTS=l~$jqRsiUN(q(nw*8;e5L) zf?nqs7qO#x7}lOK6H(#!w#1A4(eUYiVFxgyrk|c78^>TAk(+WYX3n5}E}UMMF{4mV zWJUtk`^O)~l0M2xR9H^K<{s>IuX$rT1Hi4C;_8dU^?dp4Juuki!0 zgH*_MBM+{mK+cR+iH1h}$f{}jeKDFx7bXtG{J0@#EH~piuQQ)tku9haycdPR#5=;C;~Evr zHwmC9PXX`t`uYQpNCD-xGJi;=ybI9$HeC75#IBdYPvPm@VIJ2vqQ29oG=AO%Rbuh) zw^XeQ##?71$7xdw{0Lp&nE0o#19Mob@QE)(U=lVqRUZ1ifusdom=w&LHw*LOnvHVP zo}c|Q%#;AxU#ucU1EXAEFq>c{_h2J$k&4uZI?W$#TG=bG_~2I2ozDh>_CDJ((~BZu zbC17Z^l7f%4q|3+qq{Xpt|jq(hvi0gepnOp=q$Vl$x@=T#C-Po697T7+%B$e!m4l0 z)y5-XlI<1kOUaMm0qzJ?=ai4`?cN8Rw^`Kn$scEDG7Fw-MjG776W&+1djnHkPED&s zSJXV1^;!P*wO4h<_`X#+E0IO)gsL3^VhuUhVTm{Hg=NpJUG_ti>~$34sZJr&FKYPd zs_(;+a<$4T7@~?vnSnRLpiB<(O-4}=>wwlrjBmuskh-t$%)`N%5?{)F`*fd)E~1yZ zHi?>frlFZ7P4m2x!1pHeFosq9J5Bnr``vc`=QzEbGl&87EZnuDh&H4OEzBesmu2&? zLJS3uZMD&Qhk~f1cKc*j`aw62@W|Q6XZy$Nj3!W0b;oB19N&Y4V_`vRPwc*}H++O3 zsQaJSy~9eAaSV+yQ+HjzHYPWw-^c)hS|P-ity`D%qbO^xGnIa6zRJ4G&W%Wv@A6=d z?ww`o@UW3!=1~}W%7A}2*yqsvpuqdolbQY@l9P7Ly*A#Ir5s{MZ@GihigjXUY+R5J z6A4$YG}z*{vu65)OVwg~P)4M-Pj%VGwOXGdsoBB6 z^)vpJ5(Zx7=xJw;?7ZWn(9U|%oqLl{tl&&_OLbnNaVU5 zS`nQgH&kiElOn+beDwez%NWSEw)V9Uda6ua9gxjPX!}|N z8Awx2?w_rkwGBiNI7Qz$y(`9mO{02; zG@0X}U%6DOl87fOMPs!=&BXomm9C{AqhL|LU_$hkVpKt;`K$-lU)k257)N4oY1D+5 zN^rr7XnHAJ)VaCl61Q##j?rrFlFK(qOp4Z1@-U4#zUchqx%P_S4*%Uy`OaThibfp? zO!Cf!i2;zjR6sbZO%ax1%dJVMQpycma-CV4%l;23?|BKRF3xa_@%Bbuf4^ft zQ4pg^>#ft1F@O{&-q5+=*>Yb{;VDx}S>@Fh2lf^q+P>p=1UFGWcxTrHustmU!C%(l z?{`W5E)JZ2uf^G->c`A1M!8k__)%TXbcR*M0tkItV4B}o9M8wDKnRRoH>C#cDFoaMZzQo zp8S-lT~iN-kSR-=L~5v_hD4%}Q9L@Z_5nS@Qt8q2%Ne4-61PR&n#uPZ9h{Y;p+=*q zc%9ykaa3U5CVWryrK&SJTSWHi;-B>7|f^JNy1{Be|ko?69qrtvtPx40WMzXcgqWJJ zXUJYo+TyBuO<~0_xl3;hm|-Wb@3lm6)ib-$S!o>OZ2byD%<+$O(4(oHW+@LCL!@HZ zp>-e&l>vo%O4Xtt3BD~s^kF!2v%Lm#{XqKW454OAVzq5OI{SCM&fs=6$%NUolDGYQ zm<#!1+b*1yVxq^Hu<>S;_T531u)$dC%x(BttIt=3eb}I#HQ2cL`f~lLOTVx!T2>*v zf|^bzdY|6n3TWJ`SA5?uqjk+IFy7d)+4Vc$YG(cISCp~TOgC^C5sU<& zzzBh_&n#JQ9}Hr4Uy3LEmjnX|0pRy# zy)()6#JquEmy87|qU7y2oiDMM z{;SM2ARBn}pDrouU?pU5T?Ot7I_Q&=?A&X|E6}i&S7DybzX=w)R_sTX4SZF=K|395 z4p;G4oP3tb8~Nv>9VN5sbgMI?U#2Z2#BXworm~a`kIFgJO^WaL+!n~yv!ScGqwQYw-j%JuSp3D`j+?0 z`nylj$Qk7m@qv-LrBp?C<$WBKhrZ;*2blvi4ki9zH+RNNgr-jBX0>hf$Vd&H`xOH2 zhcB#~d~ol$Q%RE6*#M0=1z=5pA>6YtZMqq!Ae`rSC zk{Bi-%t1aNBq6?lRu%Pwnnln#c|w`4FsKGuj}oLwR3IY7=qpUkM!|!UQX#%_+c7BUR4r$gDqA}=yF+>ZLFP1p7c?4kt4e~Z zA(_N-AbpA`yj^kqWbvq>s?G^A-R_=q;5nIt-F*GMCvPi~Bf!Bk0Y>YgKYnG(DI$TK zU@mX-U9cW)psE{ZqWpD=;GqxuAoMB@L#*UP0a^Klnd`oOE-Z(aXe*XdKT9;$FHC7FpwHLM7pn(WmPM~ZX2$MGeUJg- zL;D7YSP2Kbk}l*hI19rr-J}kJk|8~HI_HX}eC`?=%UNQQc+0uNVsLd?xupzL3~#@n z5F{cC$dEd@=Qiu-5VaCCE&n2Q#UVkrz`Kgw69V|Kg^Io#!lK9l<6a8q$7h;9be;j$<5S>x1_;TCMlC z4&;XgIf9M6SP>T@9!pqg@-oR6{Z?fPP6ghLfdMIi>R~O7nUJTuQvn`>~BP_$q5XZjh zp^p{>maGzpUyp1d-?R8)=|w>Q`1*Nnf2~eLb@V$VpBq?`05tMiY4UGWbGADcaA5-v zr~xRnpOQR=DkZ%AfDHyIspQ#a6(wxU&|MGB=uaSuY;2p!BGQ+EK4GBaL}4Z;*bojR zq8gpmDUftuKm2Xz%>bcCafEc-|C&^Lbb}ee3v!G~H`G{ku-f3@U)j)bCAxU(Ro2Yr zxSdMxMZ%S@pZl^jzgeJEJKO@S*~wf&=BW+z2a+pgm7vE|a(K2Oe`(ldwrC21AS{Kh z9}eMh%n1K_lsBs)xW%gIF{P+qk4}^?f+YIyRNJIjk#Z01m*|d1#k<8F2CV~;b-nwN z@u=`cXUP^#&x=VH7sJ354#ro*fPwVf6xSe_zMCvTuXlC({w__SmY;n8+WWBab)fc= zkeqhfgXIkNWHePhND5hW1oik{Rzd>mE@3+jbuB_Kq!KKGKZrVhJx?7|h4qsdjfi@v zXLIs0gXHK?M{D5Vw=yzJMTp)j`Vh%Z!Y@(8krT&iF`V)DxP>D9H#K+^t4%85X|tN* z32*|fkJ_5>5NCnP?Fga2dtX>ra2u%M${)pVzX*g<3GZ_%hGvH^2RoxFO)OGV5z734y)FJf6dqW z=p{A1*$jLNqJ=>kXBad$C`i0mMPJat)dkdx1nHinz|W(n{lZ;>PQA_Hz@h#z+9w$<|v!fHv# zd1i)Ami>=NW1nh?Hc8}gvr^qgP zr7p%%DjjrJUDu92!s*D{hSKWLED@Hjb~Vk3J0tAU_4X*qQ7##~6kWZQO{Crj%~>%2 z;Dp-!UdpPH!w7lB&(M)jtfNz;?;T%11JDbhwRE7jb>{o`F2Fvx$tn{k4HzLeI>HwE zd*^ClAEwp~18eO(%YG$O=A79kNx!TSGJVMVeJq6qWAfrK{soSX-|4Qs&=xT0N9aKR z7oyBdAR!mWqAX4NUD+7v(~E;B*D~W*?2G}UzfQ&hEyNq`dq&zk$VEw zhA9eKst;*pB~}17-|kY;{)5j!b&AVFtNq?3OT!LWvj)PZ7E9p(kWq&fu1y6Sw!By> znHHp;d||xjHfb+^&7%Ut_R~H`K^R@ zAmKkthpYzGZh2H*slNX_E4~!MN|_699^y99z;lkf(*O8H;9#4^*dxu~io4|l|ClU~ zDY2|ie$aZ)BkB^99pNzu>bYUk;yKpYh-lztT4hcoWEwMzg^K?*mH@9VJ(b<2{_-mk z0k}$jx|M2yN*c-7eKQFx@@La~;6-uwYk%2&s_Ed5Ik$a~;0CN>*y{Mr+m8O&dU`m2 zBn|5TSBH6nz7_#DCHBARTMCq;d4e|%>TRg?N!ob&B#hM4Kw<-~>Igsl7kdJR6`3>S zXfB+ZOfInTkioze*~dUz`yabH#@;HcM3iwz-DZ3)e z`hnt7W~w#0o?{=)EeEB8Bx!gc zBLGjw>92N66vsPqQZ%a4bXtF6NtC^KHtH7rl~c<%JgDr~<-6(u+=Mv;SedpF-rFYa z^FbHFAcN%@?*U$s)ytGTTmkRf^+A>;PQA&Tn;oNb)b(#E`&5if)yz~$u^)r_{vTm) z9uMXI{sEs>rKFM;OX^ez-}614-#O3oyk7m`)obp#@A=%H<+|R>burv~gEDwjTvx`kvs*92 zn|W?qUHn*&#+^%u;mnmN9oZbV%(LaA`*pgvQIQhwL?>3tJG=zAWmfkY;S*UOJB&7Q z%d$q!kX1%B1gWbBYVW$N$4C65e$#D zKRq>D11H_-Yw3aWp{{!4e(#U8KH9l<@bS5WMZ3r>$uq9IZ@y);1U{%)k1fD;u$KnN zH66y$fr7uzfvwbM;fx$q-?K_@soN?e4|{_Xt+EfA9{(^;ZqKr;XqeX#w%Wv^UEi{& zL@)HB)*<5`@AtBo+B>ptE*xfB!~5J?$01^yxxX1O+a|n3iNN`4zG?M&nFfOCh^@wb zUJfJ^u6^`M_%%+E;vVy3^%(#5Y`dJILa|{=$80BEuRDlnd^O+wZ-**m1ZLXV&mP*FJ$4to2I5;z$^FmBPtm@zurlH$>bC; z-I=zAGWKHR_P45c2s*!i%T(Q5<|hBqlJ=&r88mu&Dj_;15aWsFmIy?!{*y{l+j6Eec1c_6Te>&|uFsn2YRw4E$lmM}vtR zhf;|W$@)BE<0lLz5*gZO0}wtR za57Bexe(KlQgzYer`m(Fe+w{ExVciD;!NCrNwB0lxV zRZ2_l8p(IwxR#J)AA|1>2eH8FTb(NQ2y_RPKaXve6!Z33=Fha`@wz|La^SVN?s2o2 zeZ@y0AT;zGewi9YWQ?n9Bf=!Ke}^cfhcQ5?33{l7;N7+|p^B6w)vA>n4eNLQ${`J) zoC=tur~XI4K_aJZ?ijC{Hsm-Ul{d=#aKa{zd@%=Zbe8GEo92lEr6{=nycre*2zFNmd5z`Q~!{=;{1~iZ{ z+gvN`z3!vcQ$HUCg8RPN4#WOi#tw|D^nOcg1bubErn8OS>=t5W#Q8gPnZ!jzE@Jh`cI}@`IY2*%t*yT zg!Pa9!EV|KQbkP=*Ck1ua>4eXQ*D+}DQXUgvVM!aFR4XbH~bcAdpGm~Zsn?Ibf|1l zdS&`*(wX*$)ufJc#Dx!$&#Z*ey7Et)4CT)qzKwE2j2Tt2tHbN_5!C{vgG3XV?q%Bt z8M4s?sno4h(P8eX8Gt9AEGF^{tnK(Wz9Wg&$lr@X+RIY14|Z5}gmyf<0X^q_7z(`Y zg1td6@yL49F6*!MxrXX8>S5}P@ALT}sYM38i(PSY@AWux7MFM51}d;us;6J}=nB%~ zAoh9?YtG{Z^Wwt&hr<^oHzu)qi12F@hLqhYwSeL1=9t9h@XFd~*sUUKGy=z1p242l z5ITFQ1M4b+p(^%^`~LQANYJpGEMlgNzE$bXEMeL8I;qaevXD(55xi}j1e&W9f981@ z10E@b0*)HV^=+?UjO!xOvl@sLO0Zl?+c#VP@(=HF434wXc)j*^VZ}qeE#EItWNS7s*MUxP|ws{w#WvC zYFi&)ROQWY$}mYM;8!@)>)comG!fDQJ^qWI%Yuh*6UBlo(4;rZ&V+pY`cwpNHlztwsY<1*>xG~3sDs_uI^220K)G(*s~Aw-ayiZ7x02h zmwF*J#!+ossGsg>h7YMFPrj+!gVqiyqC?FkSDIR?9HMhe@|zwDNLB&(*X`o86`>SL$Oe-9-dPsUAW!-0S7yOlF@OjdF4NUg!9w_Su*N3+%+0?i>{Escf;I zkK|9XIJ;QO#$02uN}!532zx!f?0O0#yNm&7pn=6#Z3e+yfh=XP-EKKfS?woZ1~=8`yznDSKa+7V;Rtl)AV+h>fnWHE6<#8v+HKXyHTF8e@C)!82mHzFD#=?<}D<{@)r z-1;Rp2bal1XlQo>ncgUw$nCV>@x}fX>#rc)Z@$TaUy)~%%w*&z+h(XXWs@97m!4-s z1@ISgCYm8D`pFDD;AdT$5b_Ogb{3uyu|In>Ds-WC_Q;4CG-#%5vdlQ-ce}Uw?%GnI z7=gs<8SRpl`Gu=OUafxaB;0RFIqxTKiL77d(qSK_mw(i4UWlqt8DGFc7Tq_E7pKiF zZ+pZWPOeCp?H$-)MbM#zEFbrSw4J^nk~rR1W-mGk4u$0G-9(?7Z58B}`MAgwIc9uZ z+B8EJn6lt4pf~~(gt$e1&8ftp(WtVTGIWwsM~%jdD<$Juh!QcFkLLKwLM)pCgHWN( zsSS}pih*vL@q2jMF;biuV)~tR=x>mQOgB+g+q2HZx5?WybxqyF#%!mWs%6R6PQ4Iw zjctHJvu!R!tlu4aDl3Z#G^i`M!MUvqX$1upnX*Bmji0X1VpSTj@62K;f@%w@Z3Okr zSz*fn^Tr}eE~W$VfCIA-t%NEh$kL{Q=wn8e>;e)ld|l2#{BNkmns(gk_y`M@5lI?f z#0v%VVy2v0Yy4!vi46&074k+(T?s{5h9LYd7biPoCrEdnBDBTda0*Uk=Y8c)*@iX< z-VhCvebO-`u(?(bk+z!aQ@+U|%c%XsM#8-9vP2a-!dQqHMt)cVm~ub=6w$BzK%AIW zY;VYc2Yq`nPLFSy2f_&i4;DhqXZv$6D*WowQy6rVK2YQS40E20k3mP@{aN1_9)S;G4Vyd^HS~RTz z+`)gdLnP$+P1SXhbiZcIj#Woku42;9w}C6QB3CVS*1b?HKG?3%$0BVyV=X`P4dlTi z+mtQOQ)s7+_r0_Zy8pNRq3VCa9GFM!Nt=(X@J9~R&Pxie$ z=lHh4P$tS*bx)hvm#o}XW@Xo2&eExw)sc%cfOKIJX(poERp*QP~oh%a^YU^9VhY1I<;262wwi(*d@JE?q*OM&X3ieOhKi%b~ zXZns7#xP11*yAz29OCdJ+szk;AmQjxLOYAL%`t2p(j=6oE|BG-Kokbzl4e^*MINuI z((=wR?@!$-qCE}cebeowaE?X=S*kdMV36?@#_rKJliU#^Vl&m>i1e7ws<+ZRs^3Lc z36j@3I&}_0v17b>4e*RIC$GbvhFBDCzM*zAdg0OcOI6K#j8<1v-wV*cY%neEb+4E(ypU@ufgev}eas!c+3J^Vu)V;={ zZ2uHu1u8+z{omdExu%#n77m0nrm;}7*gV1Zwz#DlZ<__12ie{D2^829=|VfxE1T!i zpAq{OO7qLyYWgIJjtj+KZke~YS>$@r!mkIUTs%UswDm3O0he@+sVz8+yGjX$x>e^y zPKI|}fSKg=Uko7lnWbl9;IQeWcQF!$VbgKrmo2VXm)>1|kX_PZk0{?4H&vjLP1rjd z;V0|bG;|SeN6PHhl6z@A(|nj7cTty6Zf@SoW$}AE29QKsUA6JifD^7PjG1Yw6R0EwAxRL{>kAlk#Y+xE58j0V=SK# z=5r!-f8i*ob3D}AXa5RBAHEXvq><>Y>jl4ME1V6ZHOy(!dwYHqLxU%ulpTa&p^f|s zEnb!_R(w@o#B|RVRj5buL!R|QmFJM3XpsU1Ps)L(X@r^Z5 zeEr%pXzTKr2ShuD)}pmG&UtqPjxBFmH?6ijAAGiGMOi^!!|Rkk&~YH)CpNvX6FtqesHgXa1CAX*YX#Gn=vYWqFekC<@Cx7odP<;@#wkF1ur^vj49)SBDXw3qVC z=^X_8)0@Sc6^QKaU*#&LiaGz2^DTJzhp;*fRD-I-;Ky?2vsZm5zbRz9*b$~pL+9B! z5VV;)B;?+O_$(dK2&D97)14nYiZyI$SexTCp9Rfruhp~4L*hQOU2=m7MP}21;G|bT z7~-L#6m_@sjkSwKbSk67S8J+sKJfh)xJTRQd>67-XNQZa^Ky=e*`6YomdDcXeMYrz zUdxJ@)~lFjLuA_Lq>-h{wl+JO6PvA_Y)J?G<_Gvr2jRtDxVg?r3-e`9^|Y0MHtiC1 z>G7Ge8UFge67#Lpz@R3!eSK_ftmO!5%{qXJ!l70cqY3h6->~%*mWMU6CF>$yvxs{B z{{Ht_i=Zv6%f6M-@pS!w^MCEEKz?c~fl(zvJU^Ml0*SPP27(@V4g&0l6AA}@+c|WL zS>`lF2j!hCT%d=R0~ju<%`+GNc6ndfD9XX1QWi)l?Y2{Q%{+VC@nVGmgbDGy)$e7e zrcD=`4)c3J1S=u_%XLRVF**x2P@K!j%G#)>I{u;doQwd>x(`4&{8mfuMT+OUrOVfS z_Wb+pw8Q_8qVdyCv;&Lbo=m7ssvn&H_9#224*&gcI#l`A?+W&{(7XFL4|6la5KsgM= z{r|ko8yG!abrG34K=JtxfvhsbeMi;5D~tPyssHx#V}Rj-S;h8IXNrl5{hMYt$`k`{ zVqYe%iKG7^k6!q$0`${LrfW)j*Cub>e-rI0thqzhoGEM8f`3so+tN9sG75?HI20&V zr7wNS-}bqgF|t=5GCqPX>S8#m7yT7Ebrzfy)ED(O-q@YX+M&?Kw~) zcl}|p!QpU`|5p(|c$lR^4W|44!!~gCE)*z`^NGx?HHGv)zn484C!-LXl@Ao4u9=8m z+P|-T^vhMC?l(7+wz0H4@DH1Z-v7VyUtS^cy5+yPYb>4;fVEM z!0O;eXUT#6;s5;q*+DpcWzFpKZ$=OC8B>gE$wmbI?-#vM-3j|cT`mvm&-|fk7x>#C z-1mQiY+{?lf|aQLFTel4)|oIk&=mh4LqXoJ1yTO5bx9}a7opfMed)oy&j=sdxDlT4WUC=an{$w7&jgD5u(Yl zoxImTrqPpEklCBQ;8tR&&3it*4BRP{QF0}asnQ1wD^_47OMe9hGl`SV7>K8YpX&(n z3u73&aIi#UK`Yl&M!^kIG-X!m{gr=b>EgoRhg&Ro6?e@UvLVlK>BYc?o~X>{o_MUy z;92RY5&@}@^ZtFGTuXre@C2|SW@OX=d&n*fs0s_8Mz20H{OjP+o2_B$Xdc991Bm~r zCLWW86TzySKP;ZEs|f z>=&&e&Szw-PVXW!xkvkkBqQY9oe#-*e&KYOBmC;L1VfUlzm1qA$z1#SO+gKly) z|Ms$ns(+Piyj$^)gCUC#;BL-$2JZdz4(jWfWRyK5kcf0<_Sg{sr{BLAumckjD`;L#1lajYm zqo*YT&q8QLtuc1JytJUn%~+VTd{)B^`+(AM$v3L4yL*JK7M8)-7H`!!3!b&;kI)5# zP+b9l*826VLXQqC#hik;OG9)jy(b5zOUXJkP4mFEHgD;z~V)5|dGAf~GP?Wb=O#h}4MKW2138d&0K ziecdTWi!*|y!s!iHmfQPy1MHkdSn!vk_K(}w@_6`h09$gX|=#6QCbRYj=zK`i&wT% zldZR`4WHc>sdSU1m>R;DQ zc|dBPzX?x)mHCDL=iY{C!Hx?}X_w|vz%>c@XW)dzW3lP8rM=V3#=k@!lNbH_F0PHm zHE_P~0LwB^?!Wdh+5Z@Wf0>9Or-57RkDm+9q_ucL0~A$8l;DBc>DTzF()%bx=O{$r z5M&Afplg;(aBlqFptbhkN3^{C`}EiDx#A>-X|VsZsMfEM2>2AZKR9!N*36Z@a&$X* zwzBea%-`Hof6QO-4(?BvP67YMa3zR%4E~5#7FW9hg%^%DyHPo-Mc$*0y55eg9t{RY zZJGNg!?Nh|9J34C)*m+$YPPyEikm9}P`sGid`8x%-IK1}Waj4XN%G{*&mDReJ zZM&@|P#uqYp{7u%={ODtWAgtDT>khiz^A0Z{Kdp zQcJG;W71&9vpd{g?|NSz;}?j&{g92?7FWkf4IJqS)C;Xmv(9k9q%4qNdZ^PgiMg_=D-$oDH8M6kY!u z7cjPe%)GTB25Q^0L`;Zm>cYN@i zg%?|Jxbx{5CHu~6W3BGFHpPQ>HF^v^@jpifaJl^ZYYU`L)6!@B<4LvPoZfhAGt*=H zEdi3(Ucki}PLD6A&Hb)AL0pm(OsMnalpq6YG`mP9@i9UQuM|Y}RO845m z$IgnM_o@Cl-B>n7 zuQ%=+Q@J_X1mm$3YuvbHnJJ)6R`_+SKl0Hc_saIoar@7RQjh3T5Vvx z?xVgfZxI~1?4KL&|Mv(5V-{THU6x^PWqSuV?|j(MIzur~J=n1LU}sFFx8{y|x$H*I z?-mSr1@zNbJq_YGcMJ)S+*--Lv7}GreZ<`mY-p(FxfS_KXdjh22|JFbCc3O|deAW1 zkYXn=a?wB6UQ{&6IaRekaGO)&ZSs*y_Ew&65;Jq%Qpq;Qq&I!}w92m0JXOt%WalQf88N>yNY2M^>)8VKqvh_0tS4z{X$ROg z#~^-}{14vS7Dv4r8EvTZJi2i1dAuzK^YWx^u~lDCCPm_$Pa^^rr$*l7#SAm>WopW@ z<8os9GZkK{Z8K{iLc@n67KG{=nM`6?umS#C3SBbW#5+9UHNYO zy=4C`YgY9aT~FOaI$prs#H=oF$f=Rb)bMEAqv>h06XSQ(aHY^adGL2NFJ4$sv0G?q z$?dqHBClvZCBEU~i|iNNeyJRb<5OK}JbR{+nm;>f&+Awu05K;9L`4;J$z40_lv=7+ zZC|_26(5(vS{-%w*FV$Re6G|`iOF7D)0C_M)SD*hzP`SU*_6}N-kq2Us2jWk@yahy zNXagaWRA?oJm1b6-ZrLj`{V1lSikZ34}FM)mkGa%6PFKD^b?K@Nu$0FCq8rFrZ^~} zYVNXExr>9M;>XlS%hh(+j>Lo^lNS;eH2f-DZoK<576sE5;U6+PhgyoF^~DHpMoSOu z^0OAAPHhZN9@FOfKei!{*ZwhN0hg*7M<}OxOL+JBSieAF&=rr)Zya*LYe{|WUk&Ip{i#ydMUnDGvw|n#8Cewp5&k8+@Gjz z>{eTwB)-8ayT~1WjR#6=H<*1+4xZlKkjptduq4+;EMpnwZEBA~;Y=CP(a|6@mBh%T zf2dpB+B#CO%dQa`z$8zSIf|>a{>dY%Zkv3^1*WI;0KneED; zpXRsalEBr|iD#V>Q6b%v4!cpRAXWZzKU>@A^Tfj$BGOGS5gIF}TyB;-_Btl2I7J8X znbcWM;D!;bAFmuyrYE4(ZhtG~_e0OncfCo%L=W%`4%R_5h<;xEO zuUan>0<6L5*DdyxfM*aA?x{%y=rkve=zQ%h+;ien&OMbq9gJ-a4M&w!ESINxW4(*F zmuUGNX(TFQvtD=X=oRA;uYGMU#8*Mpq^zY+WdBYpM*NpLC z{f01W5C>M4%=7KL#CZGk?vyC-KuyLBB=6M-Tzy{8i@Gu-eG;Q=7i}|NcA`4nK?p<3 zqXy%8~4_ zW&W`+W)AR$ir3>%W^0|Wt>W<6$za1Hlk4x~Z6*!qZ)iG@|Q?l=v6xPN*0~S2+ z{I#jMihp@&u@&xWSi|HI0ev1y?j8+s>Mmb45k32)Cn74kzB_53AXUV>z#)9fv;fDC zYd+J^ao(cVNvfdTxWiUm#;?`5DPacp@#CknqHMY5)K~bk_?U7%NaJ#GiY><>h!p(D zb-vuyE#RIn`gLrb6pe4+gJHIyyCA4wmI+%c9BU~XvoLkGf*dyfn9-{Wp*a9Odn}_6 z;b-h0GT4R!XXFzJb+5{lYlV#bs>=7n%!SQgqqGfw7AjurohsOuqjYEH-H7;y(!`Q2 z*>IPpPKNqsZZ~KxUV9bWATn7Pp6Es2D{Eege@5yz*eVnimy;4#d!5FZ-S+I5^6yqb z_)TBavuTZ#)1JZ~^0UUcuj9)m?~*r89-3Uk~~Kxhp1wuoc0>diSZQBbI%rt{FoZU0>cptwM0 zD8sUg>?L!wanE{5%@8XE=uGq?@2O)^!)P3q)@fwaT1{IGlhE+AicC{?ig?}9a>nLH zTC({eRd<1>q$%VXSI3YGZ#Q;c9R8YT>{Y2X#eaM`o;Gj$CGhl2K71jB7bxgHqghy; zKYM+KO*s+BIjBU_EwS@=L-8H&2QAm#JJVaO*N0oZ;WayKcK)SW^PI(W-jrh54SIL^ zccz5I?mIPRB+e(+R7%f7X{w3ACi;QQze+md5S)sR@Vp*qCHRYa%l)CbL-tJ9MoMm;W{Eh6l?7oSR{MqF(>E?6!)aNQ%p8YpSphJuxx-{TvVo3Xvv5}eS$R2&# z*SB<e{3k%O4wEFXvjGYD%} zZEcq~L9rvbzy9%Mqw%`_H(3-`-eT?Lx+g-}5)NA^zbd+2CDag za>aAVwn=liq;k=eJ37j}5eo~ac3@v#;W%%hmbm-7y+FAU0C)@5I8H_jPgyEo+8r|Y zF_>a8{|r_ecVO;&W-8b2Hn%lyk{zD|pV@e8!_Oj~?yH29j^T~h4DTx1aAIR8hz*S> zxh9J-V@HKuQCHyA-t2>9PhRAw+wKAHOyHPSXP@p*1@TFO{s>`;$I|AmV)h&Uw$hIk zkh)I?@{zO)i7M#yjT=MjFdJm_r}0CD6Qvp}=kDH!%NgNu`{Pt5{OTt+Wj84sJITkQ zI19y&RSGkC+L7+LW zGePSfRLu4YQ|eycd4F`^LcV^2vay!9+EuR5eivNoLG^(A3p0%k{+#7w1zNCRcFe}j zp$YIGiun@qg~dKy^c;qORIua2UX}!Ag7D*XKmTrqb-5Sx5yOYxUy6u%YV6oBO{K)G zd!%)mcXa{ZTwq7js%x%8jLuitb90uI?#_{B2VPRsr|xryE|iq5XgP;_*h4~YYTaFX3knkme% zKb$>5$h4b8@0*$vI|};8stznX6JVeQPoqCMr?2)dPymed+c8m5CI9xjA`&_oM+vr2 zTQgahn-#p$z-_YjIhI* zPGni%^heH|5}I;77}u3^!9tq(D0jz zUkxgYUxbykABKILnqNBKM?k;z9gTWmilIv)=zKgK!~0S&`rXd>J0f)2(bb2CrdsCK z7oyCOC+Fd+kCIGs1VS$>C%CuFUCFq0WKP4ko%m7iu6@V0+%Y?`&u+l#fVM;V zlDp7^ijQ{iN|qX`<^C!ct0BE{6?vCG>lBdla+99s2ub&#V^{0w$yj!LP?K)Z(6mi_ ziT~2o15Rna+#ZL)yn8S7Q;44-nQR3p*)mK0=a>KKqCo&>H>i=B`Any;u@ee~{Xpy|J-nIMbrq=lR~LRoBHresmaRLmKIx3SV4FKsec z9VgE%HNXNJVEyB8G@okll{tYf=;a`dypkF*1=spe?r+E^4k<%s$=T6_+@D!dl&uGRIw{5nX3V~%3J&>BSvWmA^90wSz& ztzbMgT*Mw^$?_ALHnG4+JH zOMBJ21^wd`DwB3Tk>t^}ZPQPyUOe51MEy1ZyJl~)9iT&R>^$H%$v=h%q(#5szB#}0 zDmW&6q&NM*mC(^My{QAJyEll>`%9{JX-(`Oh)QcytGPsrnBrdd7Va9GLbz?o?JGGb zctvz1e>H=WIBF0zmcKaXc>uY`ld8sP+TSZ}jkE&Quwbka%fsqVp#=V`M3 z@Z>RySUx(>89QY0lv;l;*8mCKz^Y9&W2raw3<(^|9Jc%|&7ogF-M36MVv4hH8Zoiz zQ%pXenTVXbZzm2GCulyTO?mi`brZz`<-=sC*FlC`msqeby|_5|kuk#gJ?x=2Un$QR zz5VjlTJ`mJgx(M|OUx2T1cPYerL+={7nU^<1iHRMz@1y4_Uw(nK3wK+Neu7~cHkKq z>R!|LY|LbS%WSqXf{m2jj&**9NsV#WK72B!XZfP>trhw(swVeqph6WJ#&jvd*xMFd z7MzSTZ6mctJ&8~e9paYz@LAQ;3CI==p&^l1_Dkbjhm#gW+jn*>)vZ))E>vy^*`*FM zx?b#T?Y@a8*35pi`+xDovZ*i?y@Grz@%bApSwr5(+I<@)gbr4mrIg*i$5rrl8 zZsJsIRT%ot`gT~Yp-OD8*dZ5jPC_Ry<1RF<^_1$*8wm~5HXGVOr{6?R5+SvudBWvm z?LJf58CAvp6|roCuWJ53w-mS`6i#LZAEmi(?4&9O8(O`!YYUlU*?Bh^p_NDY#5 z%`)kE-uNL;1VuZf(nkamwSOM#T!u-EJ+c&fdsBe4TK7o@&|a9Uy=FI}#W*-`h|5_^vpHydCax`UQA@nMR<6lE+qmh}ht-S67> zRQD4w24Mi%(9n!IWp@YmbPKbE9PjPFQq^oA{gK9Wu|)L8e_BF$oj!TF#&yQ0P>1#g zazeE>FXd82lgE7Y*-1K7rE5ZT!b527Wwgah_pA}|7^u-n3aig?bTxSDhHibGFT_vj z_-6=i%6qm#v#GqcFthf^0BYeN8*=E-&C)VZ2ME#w4&=IlrO;=#qc_yc#9PQ#WX-}Z z!-MnBJVsMCrLH_WU%QR8-i|RolNuOx{Y&q;c)JuDx?*oa$GAGb(qY2jXRmTf)S32v z^%s%4wkyg#9iEP*LGDRrQ|fX4LrZgCu36{6uLaLeipCEtEg!?YilNP^Ah*v4hu=ta zc5H+y2)}AcTHgPpkb>W8JRw(yeD0gA86}D_Odx)Cjjw6AaZNu~ZJT+rGw6F$A$6maN4P;V2JDM#YL$5?ok(k?`gH|{rWTX$79mD#NwSc zlmo|OGMi!vnB?Tr4BImuOg!89wku)QT@QFwPaZx^u9%(kZzYa#mv8fCY;0Plt;)$` zL-AbkiyW4Ai~T(+XD;O8D`%0_8hWrpU4Hv$O-#q=cjvtdw$7v9r}-C*uJh2%yD%Dy zj`YnUM;Yp-3V!p`JuaVEMwzD@kD=L^bEt z=f_26RqW2^J|cx+$^4j5SEVaZ@{OdWI)^OGHP3bjK@!KgCl8eSqJk$Dpw?n0gNB_Y zturfxD}j3V+SUJc$g3 z$efefSiCBAG^3s?AE5XCjLE3pLY5?G?W2gr^FayO~ccIw3qi1@U1Cj zKZjD_kDg($9?q7+>}~RT9<`Nz5bI5d(6Q5_}kvJ zaFu;JPre0&vauKQLV(lM%}SSW2S`rG_TdASY|^Bc~x%*?~AbvQHT} zeN*O}yQcz#JUKmAI7pV0Y~7Lfti*|7Ty}(%+C%TMb)UVf%;IS$O1%7W1H`_$H*TGU z&^Y|uYp_S2#xf=@jg@Eg7R{FzW(5wuwj3n`_&HIQRl_s6~*csg^F zL4bj!#$JnorFhut@leA{vqU1o-OKLk4IDYly>fArUh;OzCmII{n_y3U>1Y z?27)LVFZ)=W{&N;y%J_)8a-hLvO-5#>AV}dwdn%sPM}$e2uAhFC3_(%{n=@25cFsh z{jU431RTLK3J1A7$>hfUqc3XLrv)y*OdKuSRA4_?4$ZITrrEF*Gr`v}dg zTR)U2Y6Rc5P}8?VFcwiVp4|#MS=?DQYIfh*Jpm21R9nh87BJjvF+;f%BjSWhxNlP{ z4(f)vl{8E*o85k*rjXn>>_L5=hLi8VRP{a$E^YzHA6L@Kv{*@Zxuj3B|4EywzwJil z2;TEVwxoPMVYk@^Pec_C`+;!1gUlo-Ot|^vB-+Y##A7XFY&jz;oE8Un9VAy+d(}?Xe6_(cV+BiSEr9rrsTRfxHhWsY}+uRaToJgeapO=UF(V7d? zN4Xkho9nEo@)_?Rc^}8EV(I?v-3E?H_?l(={;PK+J5Ue6u_uMMbg5*E?PFG_#bJgk z|7EsqY};%I<*<@>$3q47kqea9QjYcwqG{s!0aZIh?4y#E&3%~~ql-o~7axY;d`DYe zZR?qQNqjWtlwLDz_RO8dDdSO%j8=ymNqJxMT!pfs;f%K+4%2uW?HHWdY%6<9!RjIb zH25jGjVx%j7y{_ppTyB_%);Yoo7&=zye2|YkDvwNS;pp6y*O;NxatN8ED>o*auXnk zeN*RE)3mQ$yLUMTd{}*Il8+btxot0A*>vPRh1+LBu^s}aDrzej)z&9^$7A@=k>R>eXZAUCZx`0N z>DK#l#&$}080*JPUZu@6q{%>Fr9SNqp^OPqVFPl7IQ*NcJKes~PdoW{^}}89O5<0<%%wB-;}x2cfRcv$Fs{Sp zJTSqN_ffX})JkKWrlJvpDz?0%7}OBEo3`q1fc>x#u^kTJP3kU;`*iUk2kc`8J-AF_ zfK2WE%AMv>Wp+FD@8c$!S@faDAO$#XIa2&&E`=myuJCmWH5QD%u(^VAl(5BFn3t7v zEm}eO$@H@5J-XXkJrY~Iej{E+q7w@zyN z*a6G@Md$2UG6*7p!hkoF{NQgqk%# ztaPXZG<3LQv%KMeYI%A4(v$D~y8iY)kD9Xspf+-|Yq9V1<-roOg|R`0Yy-dbY^?}A zgFv6ue)XD=B}7DHz9GrA+Nz7dxWBCAmT$>nye~(ZJfvJ7AZO9?@x9wm$um`s(sFIG zn+W1LxJ)e$b75#Wj6X#<@(6(LVfR7{!siaMWHyH#58^0CRA z#7GFKvQOp6p#Edtk`184A%6@G(l{ECiN1dGo1~6^a{|LnklGFoRJ!4-@>ASyLs^)Q z+9zk>rUbd}J|`Zo%%J>>Mt4f-#ivE$as#(czk+?6-C8;3Z5!!3>S<_bkM9?J^|WBnF@Gg+4p7a*>KbbnWHCXG5pJ?Xoa4%7%3#@VY}iU%!I{c zjFZCIb;a`M2FW##|B^@?g`h<%+2W-22IoGCwus#zDr3z|-1~@hef+=(^S(yYjcnS~ z?ii!tnx^wzM7A(*u0LV?j-;IRqw{5Z_eqL?A{Q&)oxBFy)I`FFAHqI#Aj<)RoD9z- zqw)ioS4Cd0rT*TnpKH^epS@zL;me%JHDjiVV$wAFb5H4KPat8Ib{Kdk)-Kwb3QwN7 zQ$=}6>koo;1h}1j;H}3q*}$&PO5)y?;Z1}-fBEX3T3u$&SP&ADj8`#4d#!9)bq+`F+lG%*zDX1~d25trF_K z%_)yn$boGu31O>F%qb1&l?OwQL9fSOGW9J~f{{g41+DL={E*Cz_~puXj+yPZ=K$EK z)VjLJ-e?>;X(OMU@`{!J!Uyo&liEJ|TpE+TfDR(iv?k0UfV^=rteZLro$aP%JsDbUMzuUc+chBpI87J{mc8h2$c zzljRdv$*_vC8VeiMR$1jM!}178jxi(N8Z_Xa&8wMR>j)$?KGTi0fw?cD)tqkU4v%7m_L3Z@hzb zn)gLi6l@PojEJ0akE=oNkEDNgo}3=7WY3PfSbDNv+bvl=fKA*ziNfC1_U9#8YNvAI zd+8FfOjP=@*&$rh=*&!@-?h6p%vu+mwd2-L)z$$@z8F|(`T9JrnsF2HI!Lyc4kCmH zM}(rB0n=1G3z8(d{uvo8@`0e` zG}cwn>f8h7mNQJ5tpL0~dnd24$g@~cs-dY1&1)silE{NAmb;It_($xJfk|7xw z2|+N*J3`(icmLS@8-FS=Z>zK`Ks=uv&<1qk&mYle9LqQ9(e!+8gv69c5RkmZI(DOS zb_W8Dn=UQ-(AbD?{TTqCC$oLJ!+yren`BU-ZnACeJ;g&GpP;`_mxkCR+01Y#$NVn7 zZ|uA##4>48+#|*Fe&AP`{voHd!u#n7XD{xusqyLlF4dcVmeLx`K7fJW{%Beb@=dAw z`gZZ*J6DMfKWu8>c4Wbj)#LL*m2Q$l*4B9V{mCJ}_jQD-Rwsp|j%lvCx?2E$o@@lv z8m*ACUUPmKEkw`bA1^S4wGB>*76GcqcjJ5LF$k$I&FljFNA@MRnlF8RHvK`ORo=Zi z>Nl1I9%-Lx^>2iYe$1u8i6EaJ;tL4H*vT{}C~OqMH^kNVp!DqL;_V!|B9N~ZeO95R zlL*1DK7ETk;UtMna~wBG0nc5|JW?JqQpM&X_?;`N(>Gl&9CoDKi?<5SY1LwEp^EQ} zO>(XL0Z8!rvT$drCS`M}_GG7P&D0KqfNzHadOJOh0B22Xk z(03GVN?Sr6`Wdjauet1gQ>?zEWcSf98J^WCV~~o_UXq;xY^c!l1u1oh%DV_pE%aD` zTEO7L$B+AC@A01gXu5y9i?HyqF^SajxVjs|A8dz5-ZzgD6{_8b@BF!6nE#H36sd&I zFF5cD^~@1x8#eMM?Msi-W%8wiRy74cc++!!jq=j)6cVi~Fn%Az! zsEGS^m^VkdEx13Zwy4pugf-+pED# zWm~t-bJNXY-TU**gZOS=0r;1Hr`30jZJ(u)xJfe^Xg(t1i_Fop7FUOR16wBe!c9ub zKOo4_hdnPMV{)gDca<^jKj+@fDdk+1TDm=*898{UOu<)C+BPJYf*~#)%wqS`ul)U; z1B1n%O$<(*+dr;5K&ET2P2xxjpCQtPY@$Cm$F(<8TYWXPAhM#Z z!J6v$KK5yr*&!4;y=e1OQfE_twWDS_gYpP3^#*L*jVb*LfOelTLLN zn!rvOL+~l_<2nRv7{kn;!X0!zlh&;LZAINXQt~xmQ@tu#`DbK?-^V^1enD=<5aYCu zFFB+OSyhAR$84S(F1N4#`4Qgm4=mj4Cr2pJd--J$U~7-e`HEsD0_UDa`atla<3lT* z3Ox74hP=<#`OA~KOFq3`sNm*3OP|Yw0Kp^<1Zf-ABO*@&T}a7!f4ANj@u@L#$;8Q* z<$Ue4GD!iIw34aY#QoBUuw#8#rTO-qysCl*>Uh^6E#YqpvQy^vc{4pS{ zgrqfRbD_c>g?ThIcK+Ma4NTc!(YnKG%(sox7BfC{`CRv*Lm?mjoU^+zBfH=#Io7wd772Q>5hP1DR)RDZtWCE67j8jziiL7s4}O78_wGPQR$i$n>U zo7r-vSJe)!LbJj$b{{;N`1`5g=Hk6LRr8-s2Lj$d9y>c-?U!-Azj$+2ZFPpcMpFpQ z?!qe5L3_uX|F@16bzG^fHSIUPsT8o*l)}OMsAh8;7zX0**ogAo2W3qOgeJz#D3=i< z|0mBaDD{m6qk{*GXL@cGsz6BH@21GCiF>*$K$`T**r?}?X&GAo^>&olMfgn~2=_&l zCMeuhY5z3UOM6q)WkW|s5Mu0}i`t3n6#zcy8`rsocDD*o#TG7?N)LFr z={`M~Pr<3AHsc$N<|*??mE~1u+TQp9RB&iusziBkZ{BlSn!+X8B~k-CBR}|)zb0Y^ zRIi8D0^=(4f;2>U#|~-8l6;=SX0?@?W_l<-nWGc%aEABc zK*s+=*LTM?wQXC2sMr7z5$PyRML?tpgrcG}5v3|sMYt1Xq1*J9DpIss+cS3M z^{vd(k5W9V_rG@bDsQG{i^afm_6L#Y(a}zGdAF8Ib5XTT>2Ht1_cvo_M6oe;5FrVG zbv3djdXA@!qZ;u)FTb2ajzc^!SBBofY#3RgM%u$s>C?#4r%dYky5-%Pe&Ps^<0Q2R z%WsHYxeKy0N5g{I5s}fdwodG)vslp^YEQe(*B?%g=1Q3&Qu)L?YodKlV>~dc4^0VI z1n?rcfqNnT|HVG$t=;5RgNWM*|0p>f-eW0+GjQDzzv?^|!60BDsX0s42aKH$AwX>yO^(-zy!7Vi}mEgN0_2r*7f(^#!9wrFKlIdzBAJp zU}|?4#ZwxDQs@_>1YA2?x-qHfL-zIs!8*??h^UCp%?N}ktH{bwVPCGvq8-`6UjLkx zzF|7r%P7I9*akYV4&l|ga@)`Xfo6V~GY9H2E9?3|;f~{wrB%FP z&E<_8(MnO7%G#*r^x*G>qoUzuzp~b85q!;3w;Jc7=a{N@OHs+4gFCBvz1FD>TV~5( z`@~BdHvRJT1pFv$6rVrBV{(RxW7KuocVCTEe_A&6?B+HLQc*U{yDbfEJR+@ui1dMz zX_yHlFFVZ3=ia_YE01tI{;osy?#CkaFUo47r2Ks7)Q~8E>7s$MC+K8YV?Y@SU`=#k z#3IZ3BYZ{se2T_eMOR19@-LwR(OLf6w<3IgULFXN<}IH>w!Z9JvbT=O%f%E!@v(8y z6*(UHSs}9}7rs;_yd^aXSlT;VxsI`X1AX>>p%IOh{N%wM)>10IY)k!GGXu)pGFyQa zbSy7Zu=sz*NcdoJu7tt|AYJ9U$MxhvQ}UBD2N3k{l8^T|h4oe^zJ?v1RibKnY>TTj zHuU)MvxQf2tLBApxQSjK^Ns^X3Sw(pdhy0JAtlh$Q3gG6W4EoLyul?R&DY);!STKt zI&kJcIls&6cqufRV}>;RS~tk*TOMvqv`bUeZSb5mm0l}ugs030rZG2Wu#dMGtO>%f zz4M&xTeJw(Qy+U9x4|%pP~$4jcy?VQj9X;?L#9Z+&5xuyA*KndXKVl}`9_#c`4LdP z&-X6-2=dDC=l}<$ww_S`@0N>M??tBf@#WT_-;D6{@wteo^Pfi2%cy<7+gaj{u3FrB z8!q@?YUTgX3f5R|Sy^@sPaoPG#0XW@O(KExlFWOOjfO?u%qR4a^c8@L&(najv9Udc z5pMyNqJyT}F+x_g7Z@egpT`<-GP^j?e+olr0RbEjVJNfZ~g;x7tg0h_PvpU;2-?V|Gonp1rn+eZ5Z37OV^)2 zfBpx_1khzb6%8=Ze6Z3voBFK~njA1)>@Qx#f4e&1NB?>Y&L-&K6$gc*qa)Bzi1@$w z6|KJ54`Q(}SJ&7MXv_aa`hZ(wmWh}3o)Mz4x{hp&jDIpm|Mum0kSQNyH2?Cj|9t^m zwLzc{V3K^O(tnUt|F#Ih=TW&rsN8?Bb^reTMb=f_SKf}6fql3;|9=onQ0GzGc)lA8 z)PKyt`TJ9l)~@jTwY9E5hBv{i zERkcNEdDt1`Zqck=<5Rx25prp4DtLKhi!MP0I0)6>KFY3ewB*AvZ zUOwZ3L9wRHw!Xk_zs4C9#yqjxv=RST`RH%!Q3#lpP6>Ze-zu*`JdgGYA^Ui>#Q-%a zcUCG2=-01rgX_xOO6)`2O5yP=mF^EOL5os93HAQ)+ept8%a0fi@GELs%W?yp#<3Un zk_%}>}c7zX$u71fp2S51RCvtmC)BEc>BMzrrw1yj&R2_A--}# z^6S!KLJYb*%gW;7mQry#aEU352LNB*NgE6Y z-R@dsCM8L3<@wX$a+TVN-#6C;5mrEEuslyi`#wTbTc>@dveM?c8hU)qCaIy}G1sCV ze0)3I-n5Cto`YLWIP7<>w11XvL9f(Sy-IT{vP?4~(EPml&x*7T zE3aQKYi1cFB12+zxZwNE+X?TR=jQQ`WU-c(>@leLo(ovSJ*@0JA@^x-50qx)F>Rg=S3g=XmQ?e>YHRGM5B5|v1 z_%YM-cR}CP?#?va>emR(ZC2iltjZRjg;W!IfX6W<+21Hlfc-l`xc){>2P8|P4{MxW zJb%eOI4HVpOePEOUu>2Gm6_nwvYil3Ez#<<%w^q-D-j&m%f{0a-gBpd_m-E{*#a+b zmV>GU)xF#$DC=Q$by5FG-;w?O(8zf1c19X*A!${%lvL%9-~IReEoE!B@1bX;B{-YL zWyk52d5e zZh!yrXt%@p zSYMA%n%deiC^~|Erf7nr<2*i%h|j|pGv{UvdKDC9DRe@^4QA*Oq@)Y-QT_W`hR!HL(1*Ov8SC3lHOdMm5B zm0mK){SSBTCtJQ_&9i*=B}e(}{$(|BdJ5?(#7k@iB6EhqO3GROC&P2uhf5Ur17@H9 zSx}a8dRZCx8{LX!v-ggwfteM9mRl}-7meO82Rqh$kPr4GNcF}lR~p+*MBG9(H8q_@ zVO(0%5L>!K|K`_Rzb>;qQraY&@JVU4D$>ID<G1x;D&}&anlzWv3sD01iL?F5@`4sY>1DiHs zbn4(W!;RA}R}!WC7Ss@@jXr-yWoO6wj4{WF$4-h={5b3CFTKAu0GARWy1wedmuVp= zN8j;4{yw8{ioQicQ9=G4`~Ev$2ExUq>M(=l=O4~l&~^!~E|9AipV@$v1)Ra;y8k2#ZGWe;xzu+kGyk@4TZKH$*bk%_lJcsc7@yGu| z&A{clPuVbi6Moa*GEB%Bgn8-4T~O;0Hd@;Ie-7{L)!@i!UIpa8yc{^x{quy-G}3ew z^j7DPWdJn(hXMkudGQ?iVi>R5I(?CF6aM?CWcsZw7Yy`Y9Fl+j^yv?n0ch(!clKQY zb+>W_FOVl#mw~GsB)Y)?0mt0Tzx{y?F8JS8>Egv3fLV2kjm_qtPd#O)VE)uDK#(q@Tn9!>BCjm_ zZ_M$B`hy|8BoqudbB2{OX_ak|1&f5n!@f0|A8IR^)G;;mE2V0PKn z{+*v322J>?4H|CY^cNi+*@m|uo(M97^uaQm0QB94F8Gr7= z0=0vtIkD(9GW%I+Sy`Fh3k{6{?iB${X9SKm2YV%4SyR9A?OO&Vi5lO!jqk z{j?CgTmsxD0RHc`#NS_?%pfM{OplR>$XY;Rb+*r_bVJXXK|n&ot1$Us=_5qdUuq55 zOv9n%m7hSIVE{BzLCpMzX)OG%kP@-AdNvUWaB!7ORUtU~#`4Q4Xg{n)Xo%J@2L!!@ zKRCB#1`4jH7Y<(h&kg;D%!V98I%g9;@&w+GhP|WExb^L5iN$6!gHc;s2&d5~E$8D` z;ziOsNJ-0IHyFij;@*u{YW@EGo9X0Y&oo@g!uRX?(R1(hYH13GSpGI2Y(qoCKSV3Q zH=gU!%Mw8=zxMR>+&@^(PgP5h>Ce^?gs;`?UNlmc4szLD^ z9Q`0wBeXj%_sS}cF%gi$257pmx@+uxSOI zp8(W+wSh7Uw^V+H63p=V%W+GPfsG-uCV`^P$Fm4LCodYF>stLV|%1B`7Q z_f#{r^ZHbS{rc2>B~({f+ps1)TC0}>$W^%$Z$oTRWHs;S;4s*|KNVf%bY@Mw@hvm4 zRY2BH1TBUeeIO++t}~Qxq*m}XejX)Dx?O(aBR53yW&hpH)QXhq`!GZHMDYtKOtHJG zoZP=|h(9D!dbJimPgR3v17K9mXKBZ`wzPB%7aE^+EF54Fdl$Vj3YB}X#-p^<1i5)j zAgpLJ>oJ&y3M^3ighO{Z1%>sW*90)X#Pgd)xKRvKoE|-vDGqd5zb|3#d!MFVdXqj< zZfo?i`dx&Ny!5y55|*2wIMH9Ed^118-Ul0@RmyrPQPw5L7=dB2#{6Sq?81ZUCw3E& zz17I6>YSV>CnzXVb@B~;Du*qWHpBqVYV2&Cpi<^b^=B{Mv`7o`XWtpbZqqN6sH?0R)|wf?#D=jRT#mwk}K zi#>7DMmCdQA}QM$sT%P`n+H3%p*<{)6_G8b_YL3=+dgJw-1yV($eaggwXnIRR{2?3 zceSvosj2GEo^hb_3^==*pE|d!!0m+>^0>ItRBQ}?Fz!~Tl#J0LqI5K!6}@a)AT$?5 zj)%1VamZGZ#gF9GfNj>ojuuIai=#ECF3rC&lX8#r)2I3TZI3cDG)!;m3@V}2#?}=^ ztR}D2MgT}?Od0OhpBCuFytt4BrRrhUiPz)Dbar&4WMn876&IgH^;u8vmtv+bxyn8& zD#E8G7W|||peYx&r{M~k36!Tte_}8gO@uHQ!Tz^D|9TvDYD{Yo!12%<05@>_oGv8z zXKqDrP3Dw5JVGvCi4b8%=#jgq3}UU1)rExqw|jT2z4IhC*B2Y;4IMn!Q@S)}Ig&RM zZ)Rc=b56fef8||zcO0Kb`2%hq0j0ae_S|b0u+_23aV_AtgV^u3;MNU-=kfomp7zJm zjWu+h#`2fGEB^@9nMR(*($mwYnN-mu%uG!$A(8<_)HP_my4MHW%x%IW=S&aaQto*# z9j(YKW!Nv83m>O&{}@hPuj*iGw>p+LE&&k~5QwaVxe*1@0qacw(Z| zKW@dTr(=CZ$>tkBWPCDc+ zdb;J5Cr^K0hx~+CX{2^M9l}_L8in6H zuL?7Kp*ad9&oLmBx&lrpeO}mOdQ3}rK3P)nUcu@}H^!MkI zId2qcVL=>cWw#2MKz_nDH#dhSB&flH46Z`}FC^+$iC<_$JemU-lLZq(JWmFEX*|W& z5LiITW2oF8QMbcg93w)n0c^o4OWg4#a!-(-KO!mVx&jOl8VaOV3^*aEj)<}96E|<% zcJje$Zy*0u-+f|eeM~|Q?S{GH&`SGHCzj(KK+MF8Q-h}+L*n@{q7YPPCnuy;0F2~R zQqIa=ug)16!fAUEuTLX0Av7g1>yX>G1O8O5^6E)R&guay(%h*rFht#?O8fYUVPRo& z{i!O)$LLdpm`dK>Ddh?c7y?j5fx+;|l{Z2MwP1N-)F8eBu51Ggkj)C?6bqyg_)wgj zoc}llCBH^d0fVl7Zu-(2`BoY}K6Jybc|H>eEX+Fud5jshBHaNgq6yp5X zVkVRy=nt-sUAg=gjF0&LLL_-fVTi514zS&Kv!wmKHZ=X7KD*g%r5Nn?# zB`0%pb3b|<%r=fYF=1Ha(q8L+*yU8|vYmC|>YaLsjN6`_(vv457)c=h;juUEht*w8 zQ;nS^5)MtPF0er=%>1ylLSRse6mNd>`Vm~WyqpCgn+Up?1C@zmX@wu^dFXo9B%rf!Fqz8Q8jk7kp)D#oq4ylZ(#9PvNoB?6~-AJZY164vW$HNJL#Al)7OEoqxd;6Sx?WzqC=u8oo|Lc3TW z|LwiVdL7@J!EChRvxi`yEsqEb1#5{e;yO!hu25Q}rJkCGK9FDYY6 zkaWy~!C-9xjKXhMh6{PB!Q^@pVR!CutuD%9AT7p3LKgduL?q0mCjeMmAhUQ+fZpoS zQR02?uB&sxW{Co_006kcS1q?4ogWCOhW<1lR`)>Re4pZJlQxt+PPVXRF^ffCbG3OK2|AY?faSOh`DZhNV#KgoiH#hh0 zB3QqLq7oe-j&{6d$EQVB0}`MhBs6ruV4{O1zAwSSQC(d?;~AO`l8d2|`)Ds+bX`F~ zug`bM$UOyi_t`Ves03NTeNeQEi+iV=UggpaOnp%E^0Gta6Dt4srd{{Lo%Zo6=hTdh zw)e>&Y=Tvy1Y+*g@J}N@b%AJEH2?kTm;|HPyBFR7Vo_#0DFAvhrd7+!3o@Sr0>T}a zhuXoqf>c#>#jwOYWDi7tLkE>Kj?7C(eSLjJCY@IprCmylU0AUYe*W%~`SKtsceNp> zet!q-oacH2Wsz|!b+L^LWTA3Va;ebnXzx8;(sJ3-%KFjk$B`N^!yCSjxCH@^=kd7u zapO`H)R(Hg6;(ZNTzgF$YD=nLRNNw~C2>3lT|*&umsO79MD?E0{<&G4=0SQ^|3$UdH7#U;Uagce58I~_(9LHRGz)kO=pJ{pPP$`Sv8G1H24mNU_X?^KX zMO9U_p@jZm>9DvgAus#%vbN|>dl)ZkXEB>?&Zi-!dwi5MYz~?kR#Q1YDz^`xMA`ac zhxT)9=yDrvz?gyph=4#3#ABu<@HBbF>E$RvBbz%Xi%U_L-PPc5_^gd<5 z>gUg&(S(7DH+33~_!WHxz{?u8Kfal@VIT^#6@cha#2t5?q!yY4iA0KlqMBsE&(Sm? zB@T4IqGW2kQ;L!FVSMae_>c+VytZ@|0{0CW)SN>ma_(+h6XuDyeY0cxt(>JkqfpVE zx(HRQ@oddv)SQur1Se?+Bf*F~`mRsa`K{Ga|EM-)MOqt)l!i(zfofVFUhhK5f#LOwb?P=EO1scar!J z3RR~;+HqbP<|i$a2s-<9#QC*Z!Th?BsU$A45*n8c1KVe;2kmB?LsB&>o={T4QIjlz zWAL=LvI-*A30BbR)U)uah2u>JlJqg3t+dKZtwpAegOF~w(H?{B!3bbcB5-M{Jkgb* z{Vm7R?JAkP+#b>1AzUWPLay3kfG0-EIS;N%m7Hs(`r*R|;OOl4?CRfBc-h$II+Tw# z8CApjR_L&6B3O8-d-W>AqjJ75~*3M`W|x>~u`6Q%sM^hH)zYn|5&p#d*2`q@*CHCv)b1VT4d+0L#Y`Ar=- z!}Cr!Xuxe;wc$@OJJbhc0bjm~1KG!?6))n5cbclTzYx58Soai6+pXvX+DOFxfTQJn zv$=Ft@=%R2k*jJQ0%3KK_&m}|_U4_sqE-*3p<$AYSlri;@^r&cnt6Ea`jM=sEU&v6 zynQ{D_?tOI$%}gYqwLPAFhj<#4LTmt!(>PFs&vp|+LQQ(U_CFJr}tm~=t9g)?^W46 zrRs0-ASGKM2VX8=J4cGKtjl*ir{CPXefv#1d5JJnfIGyY;s`fM&?rtIC+$s=(l0O` zd>Q`uvGVOVlj8A^YwR@QuRYgf2$DzJCF*N4uM;H8)G}i|>d32ah}%qrllD4|^aw;9 zL8G0eUr-O+$3feAj{Vp=g6qr5cc%;l$t1nzI+tw`){C}A54ZUYby)G{cZ%%?neYbL zhE+~8si?C8MjN3G4`qZpV<9Gz@%m(mdVN|=P-{O>`B+?B;%|WQa-8%TGpGdF35;HttKCcWwcZzk%*2d>y5} zRa)eEugh#GrP&-tMr+6SYph(I9+fX6q+P$Sd_7CY6TO>nZc-K0Y7;n^m;Yn={Qyx) zE-K%0Zz5!RKLtc$p}V;})9|M@wO~fq5Dv$NzzoLvf`J83PmTh?#%crEqn{=G&MGiN z^;@fZ?h7DTJD9XWjfE)$jt0{q(8=^5q?Uoz29Kd`h=Pz6mvHt;#IBiP$d9jXbEXbK zzoe%rWgV=~pvYZ#0-Nw~JVT6jAd!fkoy`=!3usxbP|sZ&((UsM{ElO(qkTY?*8$?9 zbC@Am8%jEuB=&8Ti@pA>DPg}A9am?kG~c85s#11g#uHyodUdOBOTj~DW^|i5?SU2= zpFqNr2Bz~BVASKR*!3e@oF{2wqISM&GNOl{TZ*DaE-og9`z#Hc;!8$QIxi95U2SfY zCE!VSJxK>^I>xmT8tJGu9Oi@RLZtYlhZ?cdli-R!Do~Sw9ubyzN#OcQ7=Hn6UQTVo zhowISKz?l!L3%#65Y%0JbInnaxWFbT_Foml%xs{RkY0Lj6y$66$ZQ|1zRqR;pPy>Gj0!`KVL1mY0?wW}UQ~Im` zYVC2bWuai6A2FXdTD06vscqt^z{UWI8;Zly@Nd!m91v7DloeEuK$@M6GJ=E9gF+SK z)QG1(SRbGf-1WuAvzLJca*zgl_}S=fQ(@5PB?K=vJa(hk*>yaMPF#bF>xY~ryR1b? z;G+k);6wShOnjCrtucfbRdflw=_l0Z3@t-4SITZ=yKteC?cXM>QJnqU9pRrfAAFzH z&tMWYZKY%{aAUmWyDjRRok2c7!^~{%O@=wnL$NF)A{`yV6M66np3iHO8m+ym8^RX% zZSrR8#rm)PU^Xuz1ZqVsE%9q8y)`)D+qmmaN2wLsASgQv z&}%tK{i%HOC*U@Q2#dZfV^JGStwz%7gbDA7YBFGwAQF<$)nBZ0>96r<;%UWC1`_V~ z_xH!%<`s~cxIij^qHU0Hd?XTslx_}D+VBoSkd+PB25YGes$mFudXKbf-)StfVX(bCaDOK3TIn62^CQ1|r-g?ikT z+-r1Biudko6NVpnzUuw*}#5&%;XT>(4%ujOK{3|6jn>HN2FGD##_wqKs zDj0$%m-_;#?B${F_OZ2wa@0AXZywvN$FVrSy<0DHiqh}uE=MXoVk0#0@XL7BE)zyS zSHSQ>n3>>Y>!LW4a-`C_O44EPvs?aF0C0j|d;oJn$|vpCg$9jagDHMIYd$?fj$>H%^?ejB zEBqGQX7$-e@RmU2hc{a{J9Tn283cS>wy5@2hKD=V>llN+evMjNAjHgyYrQJ5M(=1V zW!_quoM}~q6+O!bl9{>rM!A7jjHB7xM4+ZtceKq4C-KjWevgRY4>KnlmU3RS$&+$C zc;YzQCWNo8cNv#=ZFWCe614(7G%1sS`q9Vl#{)!0hf{721fcf(-mP_>4*WIUq>xH; z|FA{+j2F$QbbylRkj&Ysle;@GnqAJF+XyYz*u)$DSy^XL1V8LDI^S8JA@Rjz6)3Lv zaNaUdcs651kn9iR6s@Wy$-gblcc)NP=&-Mc$-6FVE?4ADZ!gtDSyTw_SX9E=9@1;b ztL&iCBp6Z7^vx5rOe`1sQ}4Eu)x{Xv9`4}S>}v1A0F28b@2lpv;wfA4vL6Y98w5*w zL@;B1P<$Q>g45BJ1CWWFX93cpAjR76EQ(@mzvec~BR!}d@BE1+)YC8Lj1Dq&PPn$F z0lH%EF+w5w-Zp)y=mly8%&lw7{3GOPVZ{Exj$Vyv3>Pv35Y-&-Rzu=Tj+Fzsjg<=+C zVvs?#(T)vY6OGmIuTTn0Zx2Kafuwox+Yip!nqV2CeqijKc9u53Lz=5y>>){6*Q6km zNT{=~>$k0~3&G@K7ksS9BOTX9T7{bNIU|$IG;D}IOIRM9hCmd7JLwebbz?SX0) zO-?~Ki7;a{SKEXbIf0?H8=!qZy5_m}}1b+S1M-FkPl;Var(vX9tUvgQil<>rKVsRyt$ff9X{_#8GNUj<=xtN zxzUoqvXz*Rn3!$Db!bgYi}d5B$Ew|RlftWfTR+)LFk;qH_jb<5mcJ8T6Ly&VX0}E3 z(9d&OC`3{1ie-O^?~{7tGX5pU{@>gx-T3N=db}QeUQ>oM^_@UG`^EXaFJXoQRnCi} zugcp0x{Ay%7u!1cSx3hlzDzOY!eRlbgmV)3p#LPr9Gep719?;hJR<-ZgI0myxkGbzApX*%sh1=^H zjtu(VvimVEX(S|#wUbQW%FdQh(~*B|&<~rzoX7jYdFfaC^82%A&k98qH$vUmhA4Vl zu_J53xrZ;cVAP8G^wVx|K%z`6*z;|GjzLeJI@OM>|Bfx~-A5*|8lHgE;WwxE>-^ZC z8!+g9BasI7Nc|5pFYQyoy|K;Wp3f)qzTADiZDy=?MkG&mI_2Q-Thpd47LL_&i{$&= z3F`7{Qk-5a7QG~im4f|)`h><`yv9M#J4R#nYGhKuSZ}(#=-fZVj z3$zg%+LE(2!UvYJ+tUXRISmfKhA#=l3{o!adcxDh;@dTif@FV4cAtO#enh+*+C+`o zb{=yO(-4h@DT{K-r7sm&oU0v$L_a8WlZ$ILRyK6+9?q~^&@%0Ny zx}C5069(%h4;5>6iLG}lQU@O9>6K5gRAgU3B-fF(9gMG$GKdE|vMFL!hHTH)gFQV| z)bG_1dSs<1m_=Q3B)-NZO=w;_eCVD3J=%nktgz-LFRwy8wPbP-o0P!z=TuTbJ+ws7 zi&sB^1QN#+Kfk3>d!OP}LxiGxTS$A@^3?7Vipex~4GU^o>gq)!*c1a~lB6^4^#o@% zKG-vpMz%%zfrOjwP*TK&UZX1QH)Fv8!mFG_iwDwMN_zoUjLIWFc)ly{h4nDqb*OMt zs}9!OVt3y}jyX@+Zrvwah+a>Qh>YaH^kI%V^*p2QJC8Y&RU`*zzHuvrZb@?mLNlT}{95 z2C+5X)nJ0q`4x3`x(O~%6BYMw{vn7#X$bR(B4|`$_k%^a zntIP#RVBz_L2)tfgJ=-v>NTMqZ>Bkrc7<9uJ5nL@(w@Yq^TTX>e=F7*(!+uXtan~{ z)$6-cPgl+CJ`lOrk=1a(qy@Sl;{qO}jsTPkeEle`621)JuYTDlP&hKyCsJbucos&= z=(3tCl6B&Y2=Qn;WdlWl2+s}Yu$%Xnw&t!&h!<7Y<5g~&WVI$e`!L6EhR~vH+~j^k zaI^nnx%O$6Y?K+LAE9V11tB?fy%)7rOB!{)sT25u&g6mJ)hRfx{eq)NmK>EX?d#g0 z1^h2Joh`V_w88cum(Hx@&?D(hK3``|^;LdRXSL9G>}%UbSJG@IvR!J1ToCuUF$xyH0k^pOYe%o zKZJ*m=%@~64%}By*jf)szQlOoIZp9zG@??Dp57A-@5wMF~= zV}VCCHYd&;PZ}AsPAa6lx08MBzE{0x2nRIkR_I(EDQBDz;#xvhQqr64yed=4SB(8X zfvB%y6+1Yr80m*H-V4uo{0^~tHM^%o{@%O}H|iYP04^CyA9Usj`A;X@2ybVyT4M?` z>qw9c8Lib-mIks*r>|V$KX^{aKP=WQv*EKyhT7Yw8d`mgoNQZ;{^ob)#f73*RpYK( zV!V{EJufz2aoI?ETeY_rCJ4Dcat4*DqpA%H!n;jW`&v#XanrlaE(jsfes%^j4EFa4 z+qk+;7o4O+sKe*zi`l$Q0j%N;ES+-9#ipOg%wvVk^6Izgc%Qnw4ZMHS($q9E2Mat| zs+H^eBNdNuEZv0*lR~3Xh=(FR%#gPgm9WK7xe8Uivgpa(aZmTe2M_d7E5n}hQOmb4 zgSbBQko`j057{QJy2q;DJC`qSS1wG{9By{yCQKUacCBR-TWapsLo@mwKi6$Wm`dNi zt$(*7+${PfADzmb2I~9Q6(@rZPmIyLI)Rz*#+;PoFv+T&tpBMUZe5R;8pVC|{W>=? z@qPLnioL=~hZ2ElJUf5%m@~a;Nf; zYPk70y3bTNNJ6p<8}7vHIK!u`RyEHE$EikUnn6Wp&7-O&Y8}O0!*{>w9OzW}!Fzvtm}(SsX8f zRKial`|(za$)I>C?n=$__mK5{3X~9exA=gcbm#0U!CCMChuLc~> z4hVL&72z2kz)w80E!kNa;k|R-I_Y7$z=@{pU!E_fB-C?~Y8C^8bfcfl_;Zlh8N*xU z%2)ZwmSeB(Qg{uJ8Dyy4zB6Xv48!cO-VzdOuQ?J%GW6n@zgZC*AI)QZ;_5i)pdy;Z zw`66Fwwdp=xvh;M@%?JAt1`?2MzcN}HXlv~vI-vhHk^w$Wa>+gzL=PxVdv@nYkTP3 z6%Yz%^YB4-P4tbor&xW&WGpr>7qgylp^!(a7kl2s>G1Kk0+2%z%K;g`eC5)OA;Bfl zaF?IoWY?>m6NtBj`8tiqPNp)FRc*+0+)6a8+-5ZXBBdqTucQ_)X)q+=@jLv<=)g0= z81kZHX9Z=$D;`U+)YJ&ujB? zb0hx>akI5^LuaU8f4n+W?)fxIz+kq*YSEEA8}NE&MFYMBUKMKObYW{rA#*2epGIvE z=euM1+*U1Sn9`RkX;>wHZA4Q{89(@tFS$~B7L{+n=jelVUY*z_TM+$#-D3_TUW5mg zfVn%Rz2vov>nwIxMY|M8Ec~J^B%!Tux)ri7o6RO2e6alzjN(O#%WR>VNlrd*vm;vb zGDf86-U4L6#;&&l>Q+5oqi~KU1>S-Udck@#e+H_t84%P*V0#5wN}s72D*kxt?M#gY z=69FFnlyuN)||q?6Osd$s(Q;Z>YP}ul@_&7e`)fL>#i?D0E*8sfMxd-$|MoDpKsZ^ z|L#jyLb>=CXGYC3Wa&ams1rFPx1~u;=0j~w1frS7x0{tkQ**ah>V>C#UiP@>uG2t z>}Hqovl_KY@=eKpvC~zQi#L~42ZVcJjP+D*?+pSc+Q%A+d^5MH;!%q+P4gvI zYB2|UoXDFjDBKbv>#HcUdcDqtQ?B&lA9H>6QodxV>(W)X1TptCWVIlSjg^j%HY-ob zbxkqBY~1gCi@RTt3&ld!_n6e{$w?`b(9|Eq;*N@?n0_|*0WkMV8TPcMsHhQIa=#>G zo?lnz4$rUgS_7F47PRRThr$VpivAy@=eXfQ%nal8WACvYFB|97yR6<^?v(yP;Ytku zYJdi@9xrNzhRaEh8ObZOy8O~c+;yx}&|`lMnORuFMmld z{Ms2H#l31Rt5IbB?AJ0+38Q}Nb9IZIION@Hr*JKjLKk+=EPDtfK<3XQVrL8=yS8_9 zM5+t6%@>((ToP?C*HDbe393Dr)VhHJI*-0)Ab><_Me2cz~ntZc#?*68oPyC-l} zr?G6E)vWuw7VWPf`v}^GQnKxzxHXU{j^=n9hJD8lWBb*0xt~puj&Ic@F*9Iyr1z`t}-RdCx3EK6Ywa+t2yd}I`T$*IF zite(WL^n9icK!Crk7z0|AFUpmVr|xq*Igi+bbtjdJd^wVH8b_nbRb%m-Sq)jRp{RI zx%H#@8eU7RFN>%-yuwn55P;;bs%gM41Z*2 z97UdX$X}B}oL`4OmAQ)0Nu9hLcA~(uFh;y4O+WtvqSGm0%}p||?TM(`bH_()>ux_Y z&e1gJ!eC`h&@-7)Roe^vrApg}WUoG(KLi2c{&}sZHhEjtpNYQ~V+cYvVma$97Dgct zLX$GSgG5#K@JMAa&49Ad2IXa_Xkf_-k@fhU;)06zD7@==FcM`dxsZ`@^^4g3bDHv= zdWRRRCZ`1<3OoMR7^mn%U&@vV_rus_F0M%Qjpm$*`wu_SA~eGFKCeDb$ygpeK%~6b zKoWVK`r5Ia&Bs-sgnjvTo*F=OGP> z*5s2qEpbHwvlE|UdN)aR(mwrjt5;nO!{6FV{r0u-Okt}IjD%h3wm*Q%FZu)4Eqn>XL69~G_G`#_a6}jVY zwYW*2&1KKLOEN!ChLN1n1F5ll6KYvpDO_Szkhq>+eoMd3iWzYzEfx~i*}ixA(O>~7 zM{1pAhND8Kf1Tyq%O>dC=?DA%O@1_Wq3bL?Jv}@3b`)Ew&R82!TVURM`pukM2t2@k zUO6pdRQSd>eDh}ACrx$)r#gs~W;SD|tFEOU9mAq8x&sW#iE2@ObpP^+AluQB_UgLk zwT}cju6y;n4c?Th^)w^X61AMQ>@o~=oI`mAe3>)bJ9;JN*Pa<9Xn+%o?nO|Bo0oHn zORLL4wZnIxi{>zoEzJynWZVilN<}&UJf!aZfE1*TG{uk)M3NmQEijy1)>2avJm)VW zC@UzqAkMfZK{Z4+)qR~)aWge8Gf&TPl{Mk%`h2sdQ#3Z&uGhQXz7V0fo!juzvY|Fb z*B47R`cV6EhVyCmP;{&9=jYi$1{+J7@}!!7P|Y>PYJdO=kIT1p%gxT`(`H<@V3cL-zqmVLid4Fj3k zL9M#AYArL_2YZ?RB1p#HBzM1_y2+38XA=9Z2QA2SjCH!(mg1`miYbdm6E3Z0%!a%8 z)9}P5cdf6Hxh8f@7n4S))XvCkt!(u75HOhI^7fnX=+!n=g zxWgW#(SnM{GKWiWP~98F$)V8Ao@CcxQ;&5`F^2N_Ja@`Iq%Eq-*fOGKO?# z;DPhP@T2||+tF$*o;@v~Vs>7__UWa)75sr^OV?V>Dy3zqapKIcMGm?N@`INes-1-@ zPwpm~e-Yu2KkGrA?o(~?Zn;O_kiTcJ}BrP3hkT3_F= zDN}?>q`o_}OM_V3@^Y#G^Vb*&9s~3IgVpt9RFAzqPwt4H0iMRxnjr zB`)9C-j&%?BxAqNye)YKD|A=*hxB84dF_ltLZ@J`CaB#>EUpB5x~Q;9c|4fFpF(Ze zw)vjF%cvHmAh*w}pN+O1PH9n#);EULnytwQ)AMWU>#v+av9|Jzx%<2SRW0BlSg1B2 z=^*9aP!Q+tEySKdG2DLfip`mYMm|E^yf?AYUU9%zl|tz}z|{pTj0PqNfe`4AemFNlt*C@ash^on+Zim{)D zhDP_H(aE@J@v;Z&Pva#s0q+#b_%^z!_Kn0!Y;F3Z4tR>4=e2dnPIDu49d~BtYQa35 z-w6yLn>&2wgbiV;5zr+VFT4&uV?%{nlXc-q)PLwGpU&wnwUj37cBIdGrZXFq6bY~! zgJY!a`#OUZGtk@?mvVMU1WDGFZEr>eT)`dZsHuh6d6+#0H4Un-YQJV23$S?cX*30j z6Z!FE%hB+?A1cnkqH}j}<(V;<7&VL*B(>(?Yrt>hJQQ(R(MO&$x4dp4pm-X3yzI z&F<2;+9*#XrW&4|OX$h)oVn~^eXWL9bxMg@wm-3^8+KyEq#4p8w+lX z{G_b&D1&YKa^=TosM@w_Jjz$4rHe#nXTzJITkJ1Ty2e*=)V<^M;|C#r-eAD&OKNw>K+U5$v)a{qOwRxPIjKecn4=($m#(>9e8kkip@}0GHlJfD%i&XZf=Er_9SWpsYu9c#jdY)MDR&RE)EwayBWEjzUgih zICqJ@~V^$B8c4n`n^DC z$c^ysL1D}U%R0;R=~60`TFd2^Ni%90R^^=<`fr(*DTwFaG#JMe6z=kn2 znVy$JUu$X-SfaTe&u5x4{U9Mc?mEqg%hXY_TJ1C5r-(?$kkhAbBAzpE&8W$UTcst@ zNV7T&J#qCXw<5@4(kIEF_0~%zi}PKwTcQ}JmGFi=PvRj3z;e8T$FIjFSt`^{NsO+8 zV5RGW@h`r|C8=zhRs>ZkXbrb4@vY3ADQPlmd2l&xm|5<`t)1$>X8A~PGVh_8@Tpre zw%bdkOM25Dr@3%eiuqC>xx5}7613;3U zLWOCt;}sOuVmM}3WYWmkmzD+xVn38FduUr@oJBBbg!EyT3Dh29g-HH&-+$wF;+pU? zYUOkXP#j2oo8SdJBR?WMdhT+s4hsa<#&WM&`jq<5IzHx$Q2P?aVxX|Jo0l zXfgLlm-Y1@5)1n?!JSDIL(9S`IVojnO{#p#Vtl398j=+(bjiU<<~EHK_Jq2 z3i0+Xg`TgVKH(p`OHzr7p?gDCdPsG3wo+DnwYdDe{a5{{<{d1ESd>?fH|_qO zS!RvNa>9*fexhRjp3f<%Uq}6Fr9>dI;qXNd=C0RoMV+8IvLCoRBel1aGRgZws97yK zz*)~%&-jex&>#28HIPa4F8X05lptC5|#-X1yy1{cwwEqf?Q9?qg#Leyt7#? zCMnm5_dhI<=F)SA9K2|5!y6$o_YXt6(U`8Y+RIPVvN=Oeftqu#ZvOwH>no$8?Ao>m zP!Nz3kq$vXMFgZ_2oWg>DM3l4ySp2uLApBymF`lIZiWsKhOQZg7~pjt35FG@BV) zFfM)8>h3l6*I3wu%06D`y)}=pdmJ&VxFDKx{)3Q2CKne?J;(j0{aZ@3OZ zBu_HUrV1_Ug(H(<&^$1pRV(}0Skhw?7Tw}B6{?Kc64Q0S?()g_+ADnPU?5P*x!R+@ z`fYd7y;M`qYu$_^PKhn@dY9;08{*^UOmktqs4sHdjC@KCd`?fsI3ABH{o^dXU9QhFzC>v0vKJ@&pcPnB<&!k& zoP?auC41;9O%}Z|)ZK-J$m|s(S)nj1(l`Sxud%^xt;;grDLhAti^9v-_~S!o}8?;R|V>+yX~Sts5d7|AHx$`0%~JEC5t5_Le)L|O9H4O-6w zBIHC}v0HO(r-QtAWhA9OrdOi1NdGo*cVs#z>(u6ue7L zRNp&y@`F1~b6^s~kdHF;A!-vOvZeaOdX z#`)8Ce11LGx}Pt&8T*k~<=!$Edy5y(jm?KLE&{s4ZEtNXa3o$WRr zY!dmn_i5vB?(N4g8SB*;_3<6GOJ@q9G?Jj4oDfq=J%LDfO{7#P-OlF)n zC*o-{(9d#uM(E0t9}q9ou14OKrXQ`;ps!31zqBqpuskotf%>4+eM;PqT4Q^h8tB_v z9}Tm&7xm8x|@@{gGeK2tT#vF!X@JT{@XaF)7qxK0gO zim~(0(PiD?+&#TIn_OHKva~^*xzWVUz3%F%+^c)|)}Rdb(lDXj%fryH>g?Eq-mR&y zb|P;B21ZIAqXMvrR9n$rry`g$Hl7mxO;MJdaV$!4cOI{jT6=R?IRqbAX0f(vj@A4N zn|tgI^lgrxTy{9*olDi@bvQJXPmx1GL+QXTgvq9gCItu;j9E_#+7U_N)OVN(vqT(? z0s*{s*h^Qmi{F>X*0@3?+3AKAE#m1EXwJJ;X3op4SyPLWZ1^ zN$aJwO7j*>713)E8o%I{caL5s2G+=4mxWg@#_NXV7qtC;@JMs~ zva7)go)+gNKJGR<4IfAD96V;eQvBlUK)HPlq+%@_KY=nzC?xZ}sJ2>^oj@P5+4W6Y zjR(rcO#lY%K+0p3C^zFXIRZ){ZH6iGBdT5qWscnI)F&cLxe~3xJKA-&$^;ZVk`p$= znv?Aeo~1T3a_)R{NVE6)_%Rz1KtVsf(UG6dnd7iKrwDS3J5Hlao81au6i|nPYVh_a zKxNAznO4}vZ9GcI?l5n2A-7i2fk9}s50E@UB6TK$BT`qh2Oj4so?nKzsUT>q=s4*{ zj7PY>#kyh6c3UKI28d!hjU2%l&(yF=KE@UFT2MUfgj+0Q+!;A=PY*9mUh|;vM8Dtl zbfYCP1q?AL#=~BFg~(5zltJ+OvlEQ|XcO)n>G*Z6xnaa6+w+EbNlxoolP=ae%F6J7goSZ@L zDjK3X50dx{62yOeTVYA@iYRZFR<8K;wEL%QguNFb`Y;)gp2f*2nf6`6Dzr*LCV;FX zZ*yvT(fxGS!qUTUKu~ZMl$^$usE;WgA~5%ZQLwqG!BL|=^*0HuX)lELrix1~@#FRx zZr`?9_&zaL2!J!Un|h&jnA+Wv!btjcn_=%+m+i7hIuYG$6w}!=h=<{Du?-S8RPCo1 z9?OO0R9(*#?ib1xvrb}yf(|;rwsj|DGXofEOFHzv~rRH+V|fmuldd zZ}jXDHYsd4!Q*(Ng3oH@{JRgXM?7#eXCK>Lkm83K~QiaPqtAxEU6{ zy7N^#LWe+0@}AIhM}3WF<#@w2V)_$Z5f-}MexK@TY@liL9{DzZAcZmaXo=Cai6d%zE`0Oo?OXo%wU-osll0R?0*Aby*R&4j2?U7yh_CCv8-F^2PZ399@ zYg-QcOIn(0hT1$fa&j`Ca&((91fY4KtGDk(N(Gu_Ze?kikjP#!!2a&bX6^U-*f4KH z3voGP8&dO9glEw63=5dtkA{|#iilOQ=zL!T^{O9h2 zokA4h9U_D{bB}t(R{ywmAa1#=QB_%0iTPxZnls+!NsNy#W?b=JsTuKRvuHfiWf2w* zX-fmTn>8vQv~9RpK3b%Y8M+l(2#FJ9tn9!{wKWc)z80?PT`F9%M6lKF-9t`A^v5Bj z$yJH)bp*M(=Rg1!6sF@!SU~$;c6RWZpM#r&)z{Of!Iil3;ES0R9)E}icct0~`%fdw zBCg8;&$4^+&6P}dsg3f2iSjs=^>=z4odo!P6pp;fzw4LyYR)4F+|9vVqyz@(%@3AfJ%v^MQMbL5(1buC7sPtv|q^Q(D& zi0$nkS+r8-&;GNh(0iPx)UjGX2#*#2KpBpN`x=?xz)C-5jzh zqUN{J@P$)lXd8>X0_q z0V^%)m5ho}mn@=x{h^-YA3^i@z1GjiQuXU=FLVzc{re6dK5mnkM}Wk*MC^Qn{m3Xl zN`*ZAU$^^4#}^I(ZF)clFivi)2ITbr+@2DS`s-Euoy^RI#Zz@R;$xDTkG>DPP28}3 zH6WH-$;PLckoTE~j65^%Fgy;PC>@nwX~-_}pC1G}?xl!3QZv7xpcr(V7AGZXIvp>F z(B1vVU+9|MyaO6^!3rz5(g^^mwiBN^W*{!-)+Q{Gr+O7NVn(@m)uXy1-Bb!Dz^Xf1 zrhQV9QIYK_ek{nWz+Cudtg8V`;oh{@_Rk9%8esbzw+z}0q)bjz8HEe~^H~4=#{INJ z!POcE4loPQ-$9%)76?cEa_FSW05Lmp2JDT=t>_En26yfYcgyq8V>Mmx7&*meM&)(L zh74Sn)?mIa&@kSc_mvJ?{ZrN-fO2|s5^LrkPwziZ^dE0H^eXV69BgdBO)YMNP;4~@ zesFLAAhQw529?^zoS_4(`FgZ?&Tz$IQ*N*I9G~|et&%*vuETtoO;5vA*N(6h-1&+; zcg5qwMiS~yYN33uWkb3voR<73@xUimEXS~oB`wX zM(BsVZ{#gU$&i&sG52#dZ-$Fb^$AV#S=U2kvL&K^{?9duNVYpbIr2WJ zMZiNrPomHB^c9;{w>@OZ?qTHTf1TKWKU(myMC)Ix0xXsu1yp**xy@nnZ&nYu^V0Hm zhbekj??1mtI<(L7+YknYdQfOc=aU$){r`F^cggf8oLs%pD60;KvxpElxGZHlx528N zzyH&luH_gF`~dbr04^|T#u#V<<1ZVl0h_ESFz^OQoc?<4czHlxQt0(HfyoY`$o?{e z$;zWH&k6@jESCr6y#Kn(fBkQfcnMGf070=gEih1S4CWa?B(DTCNXyO=lK#`A{QE)t z^CH!J)wTA|PZ4lkaJpHQZVhd~ut6~h&^H!a94dMN33sJXblFYQH4qOEFZS2}v2yQT zXD!v!X5mm}=ruY=?5CA;??buSYB8ZOWw>83Tz@7MDm{Rhn$j$=`n!vsA-*=*UaoYz zKua}dS&s84udvg+iyu@k5Ju1+Y4a3MPu`JDwH?Fbr?$m>hlohVJ5X+F*pqPyXm@u33| z8~cpNb@$7)wu9nQ(`eOBa7+#DKFy=qWS|_9R$!L$zh2%EOZB+Jpn4kc!eFAO^8M!U zfqu2)Poaq#9UOn1hROM|x7^jP2Or4pP(9s;@_}jExeIPtK)W`rQ)ZTqhUSUcEG4jx ziho&mg;orJTp@o6kVA>0WJz7U??a!bH@d!EI*o~gPhjz#8#A}?mp2(#OILPWmDydHko)Ye#mutB0vKgUsyOq2;9E#oONQwtDMEX+T@f%`S z%8#SFga6k(fsfvmW%bFeHPS!Lh4V9Nq^CNl9&Cnc6HsE%zK6(29xT+kX|?^Sj(w)C zrk!($z*jeZQzNG+tIqW;5ey`KZ$6MxU1HE3Spu|%N`Sb*jTXm<2*fS8Dkx@6l)bG~ z)6`5Fry>dqRb~z7RM-AZ79XdTg%>7wBLgd~Q}JjJ3u7n~>Q(TR65V#q=J@M=aHPdr z=fDTU3GNneZ)?XpGwb|heNYnm>!H(qWX_DwQuNvTU2R_dCGm;u7dR6&R>GZDY;_9M zthKmxt)~U32({lFm=Ao8CNxTn`rcHdM9f!CIu%=6IchCnigPVq9uti&HoQi?cR@qX zQ>Tql=8rKV#*zzug}LOZC|6R2f(T zGk}0*D7)dL?)gQH?C!nNxjE1H3_EoYxX2(+^S|1X*^8>B6A&sF1`L*vvMPl|(TWuV z#_;mt$jaug7=z9I<=g^5fngMN-TxYLO+a72Kogzqc%cc$8_1t(=e?^oS1(;^QqGkE z^+M?qh18nynPUOh{j7}_A%yBZfXj>8WRi|d5_Lxwod8v-0wCJj=H*j zdDcn_jf^8e#)gXyashhYHpe%(KM;&YWePBJ>S$p(?J!x_S>tRgew|O;VQ8socI}BO zv1)X##vF|Kem)zMI}T2P#gVY){aVs}lo8wQ+F88rYTgI6R9|VN~hS!O1#Jua@?k{wFQsEv>MQ1-!1yE&D8|#_FCdB&xA6- zl>fW|LlOvHdjp*SF5lB2eDWJ9xm|9ChIzPy02ovUUPMktP8I(2XK~)Re7xT4C-4V@ z3P78o7X`S8h2aE>`!tJw*CE5-9Up%0s* zdoa0Xhjd_;>0{tUt=Br$e`h}Z_vh%mbOFA}B*Mq&m5-_yK7+EA(q!dg3*`(OaC8^1 zCux3^3c(ofWMcdY*^)UZC-_zVym3DC!?7`Wcttsf+E@H_jYZ#6i^(!|od(A*BmxzB ztu0hy-u^>%A!KBr!S}Q8k;GJ=d1~SR_EsAs5v@TnMpcqIj)CpPL8Zm zrNP(MsNmXuaV zGcsTJWu7&Rqz;Hx5M(VjIdwCgX&2fIaXeJ1uRm3}g07esG{k1Smq7l=GmMW;dcaBB zLPO5+ns#IZvh~K|7ab)TJGSMq5y9^ZF8zzJXIYnL1k~z)eOE#-#Hx@|=0BL`m5nBd zwhEcPq!?LVxJ^<1CA)3{@&_`^DK5y^4}^biCKcxA7bB65PDh*S@#*R8Bq06)>PL^+ zMYMFn<)2*+@D4s77k0_F>=yjwK(^oXSQ&oVfhY{-g?1k^0imA4zqrlwA&@W8>|e4M75P&W%S)ECN_$a=%Ze{M-`* z4rrw%d3logCTk_HlzTw3nqfznki0)f=Hy+``)D?Osb1AX!UI3*VHDJ4;BOyD17SOY zJf>m((b*g>*iQ|4c0+?PGRV^8w|63}LpDO?YiICa)fm&w(|C()mB9#(&e@v1*E=)S z2yVW4^9cB1&Q-S3%_TWXaOZjXZFGz;}PyfT=3r>3I_5_5M zx8Tds5XP`FmX35bR?e#j?#_|oj#dO?)x2u8)Y0?itW&#DU&JqsT+t}n5Pnw&uKd#%hB?BYJJ=MpR57cN)tNW?kunYh1Fv4aQn2+#tSQ9Ds}?bS zs317m@Pa0CSvgYWqI_de~Sq|?DCXUHktv~YZfDytK+PL*TfOGr+Z=<4g3 zK6>r7N-G_-l{cO74Ua!qNP0L~h8$IIh)ND+NbQE2Q&()>*!EbtXLqo?MFJrP6;Ad+(ZS-m|?O12;*Mn=N zN)mqVAem}m>kS{IjgQkS%lXZ%_C_(rZegBh@CEF&Xh@l0(BfuB<8|TY{|FNLwsxKW1ld9^6h8 zc|1pc>li0o7z1p_FIn21_N@BWA#3{}+fuh~=Ou>@vGWbqYjwG4W|Yszf4?9na;3Xv zyXy}h5@7_|C%8j@wM-i@fwMV{GiPD(+5)>;EJrKai1IJ@8)`(J%~xTm@h>;rEJJ^% zZ5Ugyy1BFJ@1u^a!%|PbEG&7(K`FB75;B1Z>T7fHGE8)l{1B+RI6KWkHQ$ZYW9roJA-Y9yW z&9kE4yW>>I%%>&u=ULJT=F)|>GMsc6;V@<$K^Zg|kBj~|smzv1;rh`+`P$Suf_*ULC$%%Eq*lcd)(ZwQu4rBP#pQaU>UOFEVIkjG51KMkv651 zhz|O-4RHr#wbJPtfpEW2p~PPZ_$u!Z#t?|a{;%c7$O0`#VD&o?B@jo8$1bHnQjRX( zx|$<&mqUhC^LR*r?-KK{1k=Gnt)7&#_5~2Z1QM)EG{f4BSw-?cS&WnS&r#`UK382~ zWw;Wvl}8?+F(p*(a9+Yf!>!|&`+3xt2bg&XNATS=)n1yr)!Xb z9u%M&`=64<-yY2^PAHM&jsqL`S@@-7mc^`U{j5cfu%Dw(?wAGUoSBjaS)fP23Ru-O zRLa^U&yLW`MpA*R)kHmc*9@)Kn>RDWPqt$3?r$|0Z5=v&XSpx%u|0`Ra&lzHS zHNQ)cgZ1Ve({S|;MCXMgn|LkkW$i4vOvPh)R>R|}v2R6q4~|(08f$-nleBwd;LG*8 z1~~Y^VdbTLJ%W!bvih+ywuG+h6a2Q<&2PUDz*5TeqE1d+|J$r@Y*G$+xt+dzjIz&Z zaDAi_lt!ew9P{llGw+M}I=605Vz2mcoP+H&PqNIU9JyX=s>Oo|G@&a>DxwY<{$%Ai zRGD0&Q~iTV$#}EJNne~Ctknsf(U6i{nqxpZ?q)E*KIIx8pU%n)H%IXQ%nA`jod*3> zzkHGw#hlNt_RhTk4?pS+oFlOG&u2%Fbjf3@Kh`4emY<9WeUnYj6mF1EfQMJn<8ir* zh3yopI;Jj{Qc9p!bKZ<@EebCcd<A7PHO}6VV^zMD zS`28})&9j?U$FY;UQy#Q{hQ(|ApRS_mz1ipu92x~u5B>9V7CLMy~}vfMwUy2nYJW? z4K_F+pD(*HU^J9-?c5mtvN4y=IFimd$`-JpUd?f$=rOZsYG>o@|3|THE+b)HD-Xm- z&bF89BTVX!JHY^izo=7$fuP|s&*GRoZOfVL9^L)xV*me$wRPA5T2xBgh$;om3Z>4p z?nS{7?@E+fO>#D`PJC9k#TtS#4JShewUU|2CP_2=QFH;(zE7!6zGL|EitF_ z@waXQKJnhz!W?0HSsO(&{e3aeK)8ML$HvX;b`@)xBw`M<+jr(k$%>B$#)(KR4E&Y=$RqOiHu ze7CqIvr!X3sw%wJlU-BCYb7R`?dCK6^&r=D(Dl1JsUW8qH$=xdRc(Dy(eh^AuD_uF zjme;H^g%>#_A2*jnyy!H(K1sveigDBI=I-3CMwNl>MCIwm@Od^Ew2#0`d+*_l^>M? zwTf(@xc8QAhm{zs8PdNsX`{{*zV{Jv15Pm*pRnloCLlOmr8L}udCguTo`voNJ?)!q zdL^KDeyUUQJMRyh`FN77Jz)Vj3F=#kc9x1b>8jlDC$GKE2;vZRek1(HJ7U^1*bG+c zgO9{d_IxzU8c-Ar-G&1V3c9Rwb>5_KTqccY-^Z&c9gL`xh7kg7@R!!QF@SDmeza4B zTT*Q&>V0X%J<+17bTnUAY`#?^QUYd`TC@h?K1;3Ledc}q@%t`oCVzs(%=#Wnse+bs z&+#F==>}(Jt!=VrH(BWF<&QWCyjwr%xIL;>o_^(ig+I~1UNPcx(e%Dl{>Cjw zbT)8mi}J3!YG@E23%|?+dN2H;)=`o!m>UZ@V_wgl^vVfseN=U58Fb(B)Qfzdqvoug zp$7I_d&x~!5bQqYT`Qmr`gmie6n`kDCY|@copkBVBo?LgFQR8iHp+dUtK72^Gek{^ zQ|eR9;eD#P0xuW8JAT-{$#3fqPhTaJ#}>_tTMMt4*HH0&DBBie!5M$)x&eTO-wju7 zB$yvCqA=_s(bRnA%!|d!V>aW#3bRJ9`&ur8!br@2?e7`Z6Ny@9y`yYpowS;-2|Li@ zzpIqNr?pCU7uI{3sMDI?6`9GBDQ45I(#raD$%Dzf(&G4Xvze&%E^$x~rieR{N<@mM zK}Hywh^81a%%x~2DEgKF?Ob-YABP=G-Fs}k_^t4qe(U$%1y1B?_=A^fKl}o^)j|}T z4b$&^5h@`q96Sq+ZLZ+6nSS*3cAwALOM_|YHWSYfAD`DsPU~~8cII8`1~IB$y&o~( zA0Dxf)L#@kEsHT9`5tiA;KW*~Q>%wNbMr+Ahzg6qAxzRcH z)K%YsY=v5DxGWc%h>mwK*SbqkO^MfXPL9RiaZ6Bqg8K|J4NwhG@rcpPen?W&ccWRl z>Bvh3lfjI=y6wSv^eX>!m0MxeBQ*M(ovgj6KJ{~xLE)#WI!t5jb+}^#-lgdy&Iiq@ z)bIx_h(vt~^*kxY7gmiK*fiFCeO#y@`8utpllWbm_Wj~uV6 z+g-K5r{Yt2yb#CLwnlvPXO=9zT5?Zo^4yAYn{BqsF6RQptLOOzI;$c$z}{iF$73|K z63-J3Io)Weedy>T-k>VkAf`W0|BFsW17`}xtg+ho2D|kF<9Gbac#l#e>nPy5*Y;Ps zqkjHm=2zF2EAQoKdOGxqY}sw8y*{}{q$3_w7@+s>`~7!fHREFfzgxs*P@*&u&&z-~3@P z|7uy;{4&P~a|H6)XQA$kVmo50PI98~iZl6#->qpd__(<4#PH+xji#T1B^W}wmE~dM zo4FB)Xid&tyxY?*nlB~0dQJ8gTx2G@0poB%Ph|*}|B;(k_?Qo4F;JHK<`iQu-4f-Z z1?o8L#)n=zi^Uh|&&~qHy+pBx_-y9cGl@<@Kk@{^pXSOx2U~izj)+!tQsBy;d0uFy z17n4MQ&>L_oNFQhNwHffQo<%Qt8J5|~5$I{{)5N_QMlD>ezY>H` zUN*g)d3we!IX#rtEWRW-I1vz{dgb9}R_xI!-*WEkA&8G+ox@JyZ; zsM?GJ(eF>tmb}^a?q~f*$iq z(g{{aDc&!yc6*zn{++ft!og*W{95h(rgf>CuDe2F7KDQU0z&9ruJ^#;@-voZVoPv6Jj$bcGnk+_4WjbySme2CRef1qwpmFVum412k{w1j0`; z9=iGN{GKtqIdI~AGls$av$^54dhMFDuc2M%_*!x+LnB$2Hrlx1Rwj}=+&+cMG>TjpF zl2>$4Pi^*7yLfS(HZ94encB>hgls;m>0;yl6pq;&`#1914si%+NpCIRh#$NWf~SA; zN2c>4${ePKfLLNeph zG)+nID@p5-|Ia=ntQ!90Rlt(Vza_Bu%?5?s7l3`;0T!nusOy^~ z`lXm}4RUGOpmj+QJB);^$$kR+bdXJ>LL)}`TYP~?w+&Th^b<6n#h`?snDdTcR=Gf$ zfsnv zAiEEctOKIg>GXAdsRHjhcUFYVn+U@4phJDwFc~= zRb_aQ%jG!K2aC+9oEE^5{s94iR>Sn)QQigWEMm4lrQ8m;XVZ+iZ>8S0tnr>zZh=*J z$Wkm`AHzI?->MP!2jumQC*tO|zJ!wXj2GqD_Zp9Bo-v`mDu_eJQC){ccC?x6l=B%H zuK~cd%dQ;fK6^r*8Dx4=H07EJMpxb9yg>@trz}6(qMk*|jRs2p{2}ab3!S1DaMYf- zD#`au&?VySjG1xitrEoS{;~y$1X+!|D17~3%($v>%awnhqpr63<6g_!qzG!6ZIXoxOm zA<-UT!4ct~xVP@?@uC*JBex#jD)V6$DOGWebkOC%THmrF=A9BK*Ced|p>$T1d6@(4 zN?)LB)smf#kW0k5wKHuJS~V79z#^pB+a`Ih%FIhNY3GOO6}QR2anjO3uATLUbLfhH z!-6JG#eIpB$eU~UCp?k#;&t%)N%0vn^F9;!y^}yVdBjx|jmSvu)d_JNov6)&N6q-O zXWSxhvA)v9VV7NCw~))xL$ntgyoL;D^E7eFx(*+{*jJC6G+y)@>ipx+DhiHI7E`!$ zl(R1`o#GAyKbZL^z0~+cv${pX8P`fuGm1iR;bgug8u>e^f2_vq7;nD`d8UvI3GkBA zf_Xi=>}omYI^$+__3mF^BqoRyrR)AN16Se{<>;PL@)?q?;`jCTw6Vx*o=8IGS|lwt zUj0hHST2`0k8K%V-%D?6%oz8zz;7YnESQa=JR_F>(RR?q$5r1;4(+yxD;%_AH_*mu zN`E1+_VAn}9jOn7am-_aq;$a=h7F2LYMxRm%U$MD*<;+<(@<#PYNt2<^KO~5#o$;s zV2;;0wUIF{VrX;pOXlX7PZ8uO&S?}w<#+0%iin!IVheg+^Zo~?H1mql%MvDT z;#XWFQrw!^Mc1CjNiHL43Do3{JCL@B6J{w$H|8h_<~PL#Jzk zhBTv^D{^DRpim(+VlS9yPpCvBdY^`bA~YDr$@7G92AUwl8U<6XgsyM9(L;7)ZJcu)@M(6DS;@qEcMVopXX!@KL6B~GWg^v)T zwuKqH@9yk3ep7|cRFGwKCOO6=>Y0{@6>T4o7N*8|l*K*e%yKz5j80~x+4$uU;jNl! z^KihoVHQ%q?lWAT28_|rlJMpe;#CqZAa^C#XD#!&F5?&RPP7x26>rx`v!ik7Dw_|V zY9pw_mzIJSfmx~3+w+Ah4$7AhQks2H@5(=)QxYGwCH*1E%{*;TXEn1)63A6u%pKZ; zm%Fqe3#*?kcW6momGDv<2^#ADvitSsHJPim!fYfirCm*r#+j>R_m5YpcIg3~YMb$; zO+=t(!~(5wa^1zx(jd$6z}mRXb3cY%N}TY|UrK#M8x%IIUWz-|ENQ<$03UEtFc-qu z8AS$Y&g0j~I;{0XmxBYmZz)7K6JwqCVk z`0Xx9Ov3=kjlG%X)++K1@^B-JsDrbUkK8xXg#ElETMf_-;VrV#5?afGgBK|s_Ctu{ zn3-yL7C0mxQWtK8hqpt!x_&^m#d6Zep0x2r?|bfl9U)UWF`xzVUO%!qBDVZhTXiRT zB-VYL5@JF1)m8h2Jpznz^TXvTdbGPWWi`8;;>ctB>!IvNJs?-*u)05@YQOVb?{>7V zVUM?O3l(Vs@oG6*LYv%n@IW;1A6eWmh)pvrG{~~h{_B^%NO1|LnXzizzEb>w^fo~F z!!PZGtoUKrdMGetP@s=q;GEkcf{dxl;af4MnaIA(adbl7R|CZC1U|Tq}ssmvo zt%NM9x8!A9P4$_7o*?Wb^n=D%;_-((_e=q|0*GzlxEtgxrq!$?*YOYl!v{WncJ!kP zje98Nf@hJJ5g934#Pfl)x{l=AblzL7kFklN!@C!TInNVzl7Pz3FT=dw&Yu?2YW0(; z(;}2ND5oRQA7yOMjX^tTtmxttdEORIUWZ%4=WX09#SA>>bm^{dAFCxrg%w;+oW~{K zI?n}e(7``AEeTFaM;PZ-k$;7`6wTD5edTR$3Hrz~8j>tmY+2_1X&*``BjD&viXQ!u zt|IoBWnu5=StUXty4husg!Gxq?xd2gG@gYG-0J!Jk9JUQk+Tp`M0&_m;XQyZwir|x%(mR6 zGg5pauIBB;O03^H#{E4{9%qK=EI+G~e4HcULSl_n9vkCFmm!^=75$@Kr+fZ+f2ye3 z9R%2fl~{ta5=2Dl&6bs@VWh~hr0oE{0}S)|M)W*JfV5m|$G!O@%3%p*wn$LdPmKoPRt+YU z2(RV!{X4Aw{FH&~4cUu{9Lv+gV{3Sf9U3 zby>JZo*j)4>XVHu2(5~3o^Mi3I%7RS+0&1ubReesWbZ65F_3;cHded1`+WLdDEgw%u$;qp#SOp{4bpx4GxfXBu{JbClDk2O{k%plNx z-`2Gr{k=s!b#RpRi2meD6@IrY;aj)SOw4sg4{VmFZx@NcG~IsK$l052ZN=B~?ze4^ z{7fV~QS&WlPd`&6b6I3aelzIku1WV`L#`x6Pkei2Bd9do>NZQ)t7JLz{S^uqCMJFg z7KgPG5kkj>S$$tRpmubi9e(>kdpI?xntI1fj8uYWE|~B&k!!vu|2M-_SHhSSWPv}Z?)u8_r)u*Og-+q zpQBnhbJShbU5-%-1YU57p-OVHi8FhUXQ8@MJZ4TcVQ>#fanP$bxBj$??$(YsPwvxH ze>V8QCRKfnhhX2PdeoOiSVgKG0CN4L?iQAP8sAqiZtGJ86Isf=Dt2Fu^ak{gV*Sjv zdw`r47UrY9fvCI)f^&}6GR=@jml5<_Z}u zKTYIW>$FvS=CzQ9UvD~DFO6(q>kyckyO$Gzg+G*u$Me#s(sA0nVz!-uhSBp|dQ2{B z3aL=p{`rTbV=w%$(3nMvzWZ8`nF$c`m5g6X6#b%mn!=rs&??rKdE z|9vBmtKF<4kMcXsGhQ&<^Uby@)~%s{!8DickUS#CMDC)eK;f3#<}zxNd*!&orivAL zAS1oUqm1*GbN?-5L6K8W$$kWe>)GAp|C_4QVV=)6jVl|InPz`>MJUkwju-t5-sqNr z7Ps*rcZCv1AOGIG#)w9FQ5@IErmNb63rx>vLUKCtiYh^{3XM|sVV;3o$eF-(XEfGm zDc*!0wyBSfjw*vL?Q83hRp-~%H`lIBCxEVhd6n?H(#hCfZl6{>W_+eHTEles7Z8Kq z^hjp3oawB_XiHI7^~-FtRW}_5q{zUdhaeZ2?gyKM6&3F)pMyRihOB*>m@Z6*M$}O~ z?YIhzSPRxs(}7U8;CG+r&)b{ubwPooe$@Omu)n8J-T0i#h16bEM^-wuRnC5#Au3i zoB(|xp_AQ#BsOL+owk#2(>M`y!Y^|ixA(83`iiYgt_6$DatwMpN~Rs&leAJ=21eSW zem0A%q_5dyy@1XpO}+Egqrz|P4+F%)Chm>@3yFLb|Cuu~Ikak_#;YRa8-d2fj^E*N zOm3l??8cE0kR3{W`nKWS=mAr{Xs^rNySfOxX;y_~E0pZXteu3?1_4lGq(anatAp{T zOh)|HvUi18__F$MZ1!WJ5)=6le$d^GDQN}OCyt|c(VJwVo!%ud>22#<5jCJB`2s{gNZBR zWbtz-pv8|n=#={Hx=Q`uCS?{#xyJF-`9ARgq(Dc`+U+xiiEIZB7xV2w7M_=P)3NW! zeA&jA0fpQsO8%D&^!lGUI2U0A<`|7U{og?I_9d8c;kF17o~g^_0ua!Bq9cmBVP#Zb zLPZRZLrZu&gdh|XcL;$W#(eY)g|$S(DPNfH@+@PXqnouD1iOGP(rHqamyzUaum=R( zJ{=d2weshBVuYmE5SbQ@B>_GaxgMMPi}_Ys#+ur_850R*o00BN->%c}9>Rt1kFrDs zYd%)3wlIzZGXohpi5LnAc2npMH;8DMnVM|7pR1Og8K-XU0C8FVy^5K?9QHmYzB~>L zwKQ9|^JG;_HcD_8FF8b|YhQGLx-2H%(1>+|qf2^jZkK#QyQWQ?-!SWYK}nr0XH!6@ zW>K@`oB90Iz*iKj8^^4B6>2v9TxDEi^3v{dLzE|aA1cVJBd5W@EXd-OhKCo^(lvW% zF=-V$^fL1!&+n6gZ7`6C!-(=6bwbSmW#MZXY(uar`2Zm=%jkZJ6T*i?F?z5698m>X zjC@ir4?WE2@_s-1Y0TbiBiY#fl0NYZYMK>Dq#mRe=Ih0yq6~iToB{FA`=pe^QQ2$m z49{ZJVC{Bx=4+D*H)c3Ua`psms+OgdTla zdYJVjR#H*o7I89ea5AFexT<8`+UXU0@yl21UYYRYTMebwK`pfn4GSKF(+q_ykqL1x z&R&@ci{TROCx(&)FngnT>fHsj=@y#dh>L!938>gn55gb`ZkXa>p5bAh2A_b!s&-N#k50qiQ>yOyir)R_rKr3KhgHomjXLW1emr^-%MoQG|+12|L-PEm5 z87QL?weKhmwGu|qu#hlfq{D*lhlRS07X-Z}r6skwxMvN^ZcKSii{-xg2O}}K|1>P6 zVMY{lR%=a=g>k8g={JoPyJnNbu8C+bZ;8NH53yqH>I|ub2JSS@jcl`ojp=#|o2UT? z&je#^f1#t)p3C7E(=~o81k>L%#A?rz;Gxz)U?eHU!&nePktaxP&4D~Ob*#11mif8U zbQ@LbC9P#;Iexz;s%D_V%^N)^!nm|AH2M9)xH!v|nH0}k?c?I&=!$~};DMP6jEX!zTLd(X^y%qHK= z{-khp(yl`>vt)9YyP5p?nEq{#9dRD}jfnHB*kxW0naCBCe9US-m_21dgtg!9DElCJ z`3h7ZZ8_5v5HwB`cHtGXP`zg7%Fpb2FDiPH)cisbX)G>g5oe^zGnucv-pRiDB#?S3 zV6(+pKyF^u4;<4ugEz#4k zK((jrI5Rv6UfxcITBk%t7Rjx>&MVa-5hbtY`ehII6{TW$h7jhFa2U_=4*{Rk!+gUz zpR_PHU4bsaE^Wg+_QN#ZA-y=aNC!XqX7Z;xT;4S4eBPW7w6CS_!jlBAF z5ru#4=b%$!9An37su~$6g;II{qdf7D(~slzn&sY|_of*$V{#hoRDIZ}rsY$uF-h-@ z6w@a>uGnzd*_!Ma?sAVWozTdgi|@fF{TUt|e1F$eUNjb!?q2lB-$>uP@!3%OSCSQ4 z6fJyd{hLg2a{TvtC9!Uq%^l%m<9y5tW(4_@KGZBhfuj1#`THwdPvdv*ZIa-eM!Q@W*dAfkXu=crA(L27hM z31jqt0aFJXJz?}1JU8Fp?|D7XfBd!A*nQpSI_L9wpL5J~5UfRPqZIS+#eD9(jSpIZ zrZr^j3M|NoB7AFK_Bd@(hY^p2*XDx1n>7XW{Q+h7o=|0(;$-=;$FDF=DsdSFG{sm& zDs2S%v$~;g(zQ4yqFvFrc)oEBTL5)GGrN!XFzn)4s?kRKiRGspZr+;2Jv&G|ag)Bf zPK?cjB?>#}eH}T1%KF#96-7_LVXaks!uPFCeb;6T2l!4TpM+mVwdE7aov5J3_xmi z@~Pn_EIeT7)F+0MY`^xY8>$_DZP+8J>Dilr*a_Vn8FDTi`^Pp((e01Etq6?PxPgI5 zxKt^k~n z(|Wk^m?vau_WB8wYao`ArD)BvF&t{I_XEwz>68JgKkxd}Y_GZ_;zk4tpu*P;K`ou# zz5NnAQRF>MkW6$AU|5Wb`B{hy!`zF)&94Ti60+|wt|~1k98M`7rX_CQ67pC|I$}t+ z3r?M=ZP>J2F+wXe*ZZf-bc9(#=npSz#{`G;9UU&-qFWyM^~9^I8sd7qjHqpOw`*?m zZ`3|OnO+aHi1E^<@}3kC9~zQr+*iUg;YR=K?!c1 zMHA1cS`nxxQ$zI*LjLmWaBLa>d0z4@;Sy@_58~?xTSNUZ)D(Q+KHv9F6y-wQXMMJ0t?RqFpdQOA{C6z0a}(~Z2=D!^pnp&$ zOTIjoNNY0O6lB1N{+hbs$mqfRjdYwLVh4l8OZ5QtauSe&I)6H?z3)=!DSTeBH~7}} zZ%T&6H<qFP4EA?(#LI0*?281M7!G1-5 z*0#ajc~8ojH3T#yx*n4W*FM;9qvNx1+JncMAX#u%a0OqWoo2qt?sC;w$&=loX!_Cw zyWa-obEaSpBx#8V&vmlo#NuSf7PK^6*X%AX zx26(AIUqIh(e!+7Aw`aCiL*yMd0Kr#*vi%zM87t&(%QAR8QanhytVg@6zU(zegiulM62F;n`I zGmWjT#7z$`_L8WDud#{6+K`o&ZJVnzk*=vE&5c2~XoQ_v9sX#4cNl1T^RHEk3=nxlF7b%A<~JRY)2y2P$7IUd#KPR7#D zrEzWYX&rgdB}xiKe;}ehikcdQ4#yZ1re#F(yELurdp?-eIJals9X;$VN4if9dQW~( zxam*4;bp4$_p<1m#;c*L{fd3heQmLupXUySmSmuE-ViNH0V*fjdc!(DT2V@xC!5cG zPofiPr%pvC6Hp#ZrI5t9c{y|c7jW-_W>#@e6Bwe0$pqUrLjMl6|G2{t3_N`Nd=Gx_*Hb9uN=RGAuv965P0yF(MveisenE_cLi?E(?+^f zO9v0rKlE-u%(ICf&?GDVYqomV#swvth%WzQzqbgIoKjIa@zhvVkbjEJUTGM>O6R#r9h`k((biF{kem&&cXqSYU^Fk%cDP-xe2G< zlx%^($lfF86}0id2he=*l-_`Z_=Yda68ZzbAs%Uq;tD2&pbi!@8M@l~!6ENLre`snCgohCoc-ayS2_bv4LwsnJ z$e$Md4qqt9uwsnjZZ?`9p79VuX0ny|IwKZpfYmat=ohB2t*ODFJxLMhIhM5#x*G0D7vTXiDoc=sVm3xaA2-brF+8`FcO5yS3^+Ly?*csCA$amRv# zx88wUh2V=BxBxMHX`n@d;Kv%jUE5EeK7X#k23lw0Bo5Sp9iTst3%Lzr&%;)xLBtc6 z5l7HdAnO6vq6?qoC(x|+;T~ZUa&2n!-juCY?48zoukHHAR=sFfD=5YEdg(4Gd?KHy zJJ^t)%ALV&T(|597z`&jtm>zkc#}!peEnLy*YW$9J1;{5sZbAUP4VP+;P%jo%mn2A zL-MsrYm2K_4-)-YTGdbo=7kaCT#0}E%E#pslO!6^&yV<&fAYpaVOwOP_eIvRx0xg@ z;LrS@lv~NMGcxNyW;rJmdqX@~xg2fA5_DR^N{nOOzH{=$3=iFROZec(I9 zZ_)DHreAv+^HS`%rU+MS&!m7c#c9^cZmP`p(~lzs%n`k4fZn8^r@p z9=l`-;PtYd-?YA7Yj@sPUR!ec5XverH5QieoONzGA@-76F1DJh(|c^-0guU_9Zzxw z5CL1cw>bdf*Ml*T?qEStTC#g}FJ~?lY>DY@1Yb#0I%kx6*OvXunmzoOc!yT*4jeTu z0r;HKzH3ixxHOVMYFakPR#m`85uu-Mm}>-ho|%Vtgx}bZd(jPWoJ?|tit+gmn#u{m zCnJF@Osm}DliR8jYu-U51?Y#GZyPr_Z+~BEET3PRX%Jk=Q+`D5vz3qPJ5muNr!P`P zZDqf`oFQ~V%~*xf6t7|lB*~276Xv=nJwIrWkJ6AN!)tFCA>L+C5)rnr9ne@GJ!171 zuq0~Q(=IE;M~PD_qtNYV|C1J(LvWMGqYXxS8y?J%H=knL;Wpjoj;D>6BFy!svZeBM z$~`-?jWNS3EeB~sdiKLLwBxMu9=)RU$UCm&8M4{xi4qY+nCOPs#$(z&_3S!#W%>Sw ze<6HGolKkvg5r?~0_64`H?4RG%Q$NpIV5*-@-o}VGE5LIPjfOkIEC#WosoT^pSScg z;OF>4aQKxUeAsTEfb-d05*TQ=z9@O6vv5xStn`Nl&|p^xoli(xz;UdiqH|O$?CQO$7;aV6Zj3|FSzWos8dq)JsZ_N%2#F-v{w%G~Pr5I%m|uPZ z`gG^-@Km!mj(@1EnK_%wJ1U_^V8SYd{U-hXPlC-!uC@(mszdp!hc?x%gGtlFQ-q_JSXPR+mWRnI^{sq=?#_T(YWXD_Vpo(NEX^;N*2E=OLy^u=EeM z5)~*#PCpF(xAU0Xh62ARG|GFzq??NK-IYAK3JXWy7Lbu8kzxrSM(!C_FHY=0Jr6?@ zM;^bSKjdnRC7c$}XM*hI_x2d^IYS9yNc{B}Xr6>MR#OB)&YQ^(ve!!f{p6Mtg!ZN5 zcjBPpVPPVKuD(C8FaB}hT1cStLN#5ztYS8la+^DN!*D!ktr|lgVwZyJ6KNDjB#NOLbA5;KORa__#G_+6OV#zy-;#Vzc&KvT?J4>1 z85L+`$rR~ii?$C~$w!~Ax6o1UeFU#6 zO&fKx&~ZPyFPB717BtkmC%xo!cubcB0Y9`p)BLYM2s?hAD2%w}oPwJcXvDc=X#t_a zBbFjL`iALppsOnnoX{hH@Z7}d@jF)%dcI0E#>;j3ks^Qgb2*p23jJ~~e{Q5-)8mEx zy-67^5rk!Ypu2MB`;T`YeY*G_fM}B&Xz@O>(&%|%+1pg;*(vT9^mS@d7X_wm=Iq0< zcR}yJ+zFwQ>b*=c<3@)Lr0=QG@fsqO^ZXz6`NOc&RgN`DUm@TJQvD(_eha0_@9ItR zec$Hs-IbeY*v!I&nf8Oqh4A1E_|q2~eyRWJZ}=R_yd>1yZkK=}i=SlG2L6{gvKa0H zxA>TtT6RPRtUY0bAdJ(kUphuQ79;>&w!Di5Mc-Pyt2f@UF#0LN@Uz?w2=lSqIba`y zjj!Fz(m^~g^^!Ak?&xHfP(_}6{FYvkWZp{cm?=lBcNd7%A^cNVeWZE3>W~zcFY|+K`KR9=RHv+P z&#ZOgTS`4X&pQBkV}dNP=O$ccATTbbHU|p8_;%%MQ8m#gDM0_u6v(2+tKE~8+U4eJ zBI3$ndk0W2XS|TRZ=_?}7#_-UtczBecD{YUW9ELk$gUCx@yROV z7bh@B-dVrpPBoVg^2zN6*ok@r3oWOI)?NdQNct&^*$>BN9`Tcd?tl52*Ab!3x9F;! z!*YOm;7Bf0FgTpYyu*Ocq67OwX5tx}EbAjc`%DQWr2i^{ zqn@w}aUv;ssQ2fS9s-HZgkZbZzH)n`z(9%gHvJ)MR2(zXq@*d-Rdhy0x{9;Pn(a;A zWel5dtS8G0s^4$>eQ(;ayw_|BSj(Kz5KH$Izr1|{7r~sZ{NRhiD-l=zEq?PsG|BOr z)as@&;3$ykbj0^JMR$L zy!*T1KsuJ0FYIXb)$wn`PLcU7o>ysnwM(yB4mwkKIXaJ~s|&m)Ia~NIJ|pI3*v@?H zUY>iAd{dyF-}F;%t`py6hknu7@~!-3W3MD;*F__(SSCL0ZHEW92yNe$o27eY0Ocgz zvVE_`)BRm0PEb%V2@F;xEM#FNGca)W9rBHY;#n0w1nMsvMKJ) zi%XXRoYfRx=%bp0JM}A}|w%cuDU3km3onN!b@3=S6oT9o9; zQF6#c#{RQLH}qRB5wI`)cyUq^f`N!Ki+2JV;`latyCpu;eAwDw6@7#J6RRJQPYH>``K@`ZyQpXj#@U?uy9KYuf{odOR9@sSShP;%r$TyE3mkNsnqjp-h@RdhBAX!CY#PpPN#xZ#popQpmmD zSz}>!80sarlII)SyH7Q6eUiUb#}74&y}{y(&6zdwL15Z!goVFJ-q)& zO%AlQB4(xQFWS$MR{oC1ye}?KK+sHhX9M9dZ*j_w@CYNKbX$~t8JeuB4CjIV0)^1yEK3)Z=zP78Duuky z@mqC=Srq0%XR0e+_}V?g`_ATp>iI1BRN&` zxq#~@9Rp$ELZs3pEQ}BgNtN$o2z2}w(=t(kkdzcf!a^&yd8-Lz1CbvQbuRq@ye)QN z#HKd60zQJVujr^r!&-rTJv^oCVf)GLG_A|BD;VwPYWpTs(-nN?MPED4txIoCDHGDZ?Y>rQ^eQSG=t#mBsr)X!>S#v&%Ew(FPCp*MeO06GQ zm-tey7ACG#kF4?A{#`b1k%{>jscEv&(WU6Pg;qlL20PZhz!=;O`WM|R<}hkpf}_B} zQNV$u9|@tq7C>$P+56K%lATKo!Qd@=z~Pc`t?@U%U8m-<7DT}JPWintZts7}nW1t< zasjLK&42FoRrU97Ne7Mopwx&8cF)@$$|8@!18%r6i4|1si@GEV5#YNNvwZZ^HXisH zj3T09VDn_5yeM^~JE`8|@41o3&ct*aCv03>(9birW=n;4&CSeT%5S@r#B3AzvxLIn z!S$HoE>==(DC$Kzzg|^lz2mf6iyw8;vq{`w15;y#OLoV(v1rc2zu7f;=imR#bo9(_ zIGW)EAR4b&X)jp`Jt)v1d!$4Y^JveTz6%^+Pt>Y%l9rG;`>V4~YgG_&Yum>0e-HcLcaU=|b*G^XQ3Zdh7)p#nuXTnIh-fw3IEU94 zBPjP-#C6pzm|MXM1uhDDVckd`bJuq2bP~ag>9_e>(QdJ?c#ai-gp@z~q zyOBxCDD!apnfX9$V(xGn{Z>rU15nVKOOnlNMot8{6#k(SLj1bdtlyQfON?;ZbkD z3H3<#6a%5Y*G3j>8@(VFi_ITk=Zz@!IDd2!1oUY0& z8ut`}oN=LS3@pWX4x#rL>{eUgvqukK-(5?9tI>I5d26baE`ezAYYn;T{824`sz$tH zD&M+@nSdk$X6a+^^v%8s7ARt50NueKH*PrZjSu+IS|6C$FU+{L`f6CQY;x$TbKGU! za$mTYH^CyZKt-yZQc<1W(3a$~SOYHZf5kDs6t-I(RP6U!q0 zcH4Hr0s6?JAkaOOI#2P6ZiNjc7@h7PBPiX!dQKl4eX86a1+*}kVq8g)l{2=@;}}lq zg7<(cmah!LkVxdWjggnlxB#`enAUe}JGXa0be?&`>hA-geybdy5%pJuc$?2>a?Bin<`U89Q^^J{J3HWgWW z3DX+nu3%IAW`%y)n?_GNJl~vNah};yd;Xh7y(ubhf2<_ra*xeHQ6OY8;A?bD%GlVO z*o(rgM+kxF2qiF6$AH`*94|LA^)y`zc$92~msHAhaq|lS=uVI!h;3g3o>FKX6=yuY zsfuy$yiM5@L}h9#`)Rv~tp8Z}eH2OZCFzY!fHLEZK=JB(5ZImh7OP?wbvJw-et?zK zaGX+$Wcb}C$2!p~@+p^GSXP{{kj&2od@IT*;Py63rk4U#8NfgEb!5}=R!y#s3{}vM za+FP`r5V@d=Ke?}>HVp-fS~)bDQGM5 zzM!OAzYMwe!eiS3R+iz_VB&4&U*j?=Y$`=pQ;GWHAi za!T}o&vYe*-|z%mxFFqT#I^<|c4&3P2yKC;lJ~QI?=s?k$;jJRMBOlDW;*F4fiJ19 z$%Ku2$#!6QQ5&`_2MoKMnYg1-Wgf8^}-llngjHTZ@jYd)bs?1S1%z#yH(^c6hX zD3X0{a2iAU*%A>$>e$f7v7kGF`a%R|{N;aZ)MpQ{frXs0(^Ehkm!Aq+W{IM1(5(p5 zZ>YTr&&YsF3WZA>c*yY&FC`af zt1ZL+EaLoVlFb*|L2SnlN#onOZ7sfD#<9t+p1O+x>$l^yS9!vdp&zo|bG@8Y+!e8|zfS2_L~LRU?GS(#B=Kevaxa zoMdc19e-z9Nr|H-ij|3JxPD6n(Vhik+1JWJU#K=FMDo>Fi=MPa%>j2n<{RIonWj{W zzcT(6=(YMfC;whPWUt_DF|b5e_BM4QqS!3yRg`wqHG5Mn9EDrgji{QKXg@^<8|mm% zp?tF~IDr#>D(W@;*0gaFueKEg-?KiN-I=WTLIwn24MtV9_D#P|YBtGI(y!D&)>=^Y z<`Qig?k4JGfpp${_8~6XbLqKlPGdWKAhzHf2uBvSx#$0K)0_sooL|} z+bBBhpX$bJXoXV^m&yLbP!<9h^HAY=hb8%Ut#-HK%zVdn_4LZ2aT$OA*luvIEuCUb zUbRgYz3D-+h5ZODmb=|^ZpCVL^Zg9(QocsANmk5*rcA3> zgIQJ(o1XixX(c-2Yvf(3cqgQPs3c^I_J1jNx!O<8KB6)c^s*BvBjp?}CBN2WEX|0E-ZLN(*I?G)I|*k*=MF#@`gG5btX)83 z@9(>!s74Py>qO!?2IKrM56mWr*AoEZFR(0G`GxD4nPWQtZqLv z^*Q`oSk40|r;@s5nK~0VT|N^O6VN!W?APs6r~Nr*a2;r*FGju z|EY$EVl%&G!>B}>WadX!(U=-mR%aVY2Z9WM^u*G8`?>=?+wYXBFfUq+$rn-sA)exT z{hIGP@dAAj*KhkfL8$3PddfU+doL{6HabkN$EGz$M;jFk`(9fY#Sj$R{bHQFbuY92 z#r%-jPns_F5<&=`S_R!>p>b>IH)cRn0kpccWjaIT zAaIriS~xW`GJL6S=<3Cj!c+8tC|(|SlJomijbrB0MYZ*CMaiz;NzRIM@UUS;gLAIO zt+^aPvU<#hQ}quP(7BZC?~^wQA)A5OV3G?$^B?T0%{@0c=*!;o-)!1qB$?~l#LfCU=_7poLWVj<25e6PE|AGdnM)=8`i!BsXESPUFE*W0k&aXXti?BEV7 zHt|(!wW2%}Qvn`rp)k;0`|~efvl;7a66XN{(8=X1 z!S8PiM8fy2DeIkFh&+bXsDg&Eos$pU98*6Oe;w4L7}|`Vz1Z85pTeA74ccS0DwOnd z0Ot7OM;FO$-~4;3iF8$l%u{Zzr)Ag+BYG?MA#&$0;X-?_6hEP=BPb+*Mxtq#I&Vd% z&=Fu>;5+sYMeeL}2C;kxYtyVPWV)ruO4j=$n5*lDrENVFE@Rmtrb1voypq$pH$!AqY_cH= zv;e(0?pYdRwGql+vW9_nC3$V!C1g{|l)BVqM7HAWa1-)9-#5bZp7V?bWXUnwQzS1*J^Z8jFXWjhg#z0M$QMuUt z4Xb`0SZCMuvDqMY2Y9n&i!@T8ZHEx$P_WHVaJtO6 z!-QBi{eSsfvXNO@B?99YkY3b-l;KacgxBj_8bgy3e2Th65uUs~Mn6&o=^p{B;vK>s ziU3`H@v)xPMYJ9>Z8`RlLtBi0WR7pyFg|&d#%GzoqW50gYKbgGu*3i4I@Nf~3XE8t zp1E|IKXL!!&O>Dix`Z>!KFDw&F!PwyzU~~Lp^4uVKH^hUqecDfptx=9d9X<}iA)TY zb?o|KcIdKwT6hAgcEUTLCYCVszLHTq;zu1t|BI{w1<8^_zsT`G7|BpHg=Uci+?6XB z=eH@HDJ1=+^8w4%VEI72IUo=Sao2`?(RW>VV+rgqxZN8WP+xfwy_e39#-mP7w8IXB z&2Q`A&v#Tze;vd>H-zcJkoYMbOTwpGU+0S(wyj1v$Ww5d&>P!JZs!2lSH76}mrmz@ z;8}M?AFIY4ZazNL)4R2A^fgayJMLp9?wExi7*Nl^Q0RoRhH!CmP5_v#g%8~OG`Me_ z3O2Qs6Rc_$QaeN2*XvyHrJ*_k!glA0K|>Ik8yoewwFD+yl)WGBv)!v4gBX|bK$o`8 zNc&C8u{%optmh72qh`r}W)GkOfqhFdDj)+H9?Mquy8REVr3=*--Z8qbK+eJ~XL}j< zDj(%Ajgo?02Hi#ZtiZhm3ouY4FUbwAd>8sYQ=Ib&Ws37yN{?G>NqDMhSS=eGIDlpY zZByv-b=#w)M(! z`CD*Pzg6(hrYI-(G|3mw(z6>6r)HPZfqO)c&#ZQv&7i`Bf5>5UL$pYDIw;d*DfHv* z+Ept_dg5SNB1-aulY48_+3Wm`8or6{R_>vX#0Rzmx&Pn6_@Vyc^k@+pWGjJmT{O|F z;MWPWUafzvY3+~-_u zHA%!>_z|Lmi{p8gY(Nf}aMv2r>7;y8H$q-p?f!(~(BnA}ObG788wD|vPl!wg>7Sfv zmkGs)C3=DkzjAXUP$I;nr(1?`=;(}B0Qyf`02n6$(G#RFF|9W{W^^`aaA*2HJdad@ z^GE-CIVG8AW=6QoR1u7Ii5zsrr9&1QodA z@6M`hC=mJ!Y{N4#tG86ESs#Q>tq3)3v&l0&5%*a1Th#+jLIJ57psX3Ey<;}6Q3~P? zfGKJjyl&F-nusPY8XZ9|QY6N2tXPlVtVVZ)oQi%up|4v6SUu{(L6a!Rnt_vIu(OGd zvyQ?4do{p3BIFIdvl91IcZyeeh~j`jH)+H>1j=vPEXG}@PM32D$LA%ruAjXi*#AS) zZ|87kOWJFM9y*FfScV+Gyqkyx`!7_CPnF1eumDN_(yrmUI_IGNw#@({=%}MyG8&623=V{ zleG}Tvc3L0@(f`2Tiq-(aMwiPw}8KGDjvE?1CPlS*kG86Q<|3{7s!sJWO7k*`*Yc+ z`KG7{W5ajiZ4OGev}%3SqrT?~dq^N>-v?y{%FPr8#$lA#{lLEf7$|htY~Jt2$X9xo zXHmD=8kY#Lzz%=(i}E;O-CoR}W~a^k>-A0wa)Jk~Zp5mzISLy@^CFpbRAFHGZQV}m zTY#Tr4LjU<6SnYk%_QsrF+OOjP_FVZlX%oW`+}=UX`$MHrBy+?w3b}mq{Ma6))TE=XGnx>zd!%> zWrY>z{eAqPU~;YI<_0wKl`4( zZP?pixo6QEYM!kf!WDL^rUV>;|J1+1Oq_XtT$1G(R@_qURA8O)mbxBEmwUs&4yMb)iODBD+e$4x~ zX|vqYSSyy59ljPdfwz%4Oy(Ii$oLxguP5-V|pHKhAeKamrdVK;K1AS2H# z=cT(Y=2%5tFYUMf2<5+>48*#@aT^+;pv`;FpaA%33n8PAD(P*pLCR^Gci1mJaGGmL zhR}jP7$MJ^ZcYNqn}q*ap2&`i%eQEkd6W2oO{VBBrF%?E<>m?=^c1v`F?dI~==dqEK~C`g6cXofJ8vCkG3By!T5WB#t>Y0kC8o&lTju zpV?ae?Me-d6ZwVV)IgN^FBuhSWpV3_??_b%yvVnRFeFVb$wWO81E5_N?-GBRkmZ1! zA0Kzx>`;7bGs3C^c|`$0B!860q8tPO+AFW}7lA32>kh!&pW&YxIh4QmHRqmF@Q^(ovcO<;{y zL=o<6mV7{uQo4RK#<2dD-h5m7U_3w{`$6Xxxu(RbuFXbnI38*^0Zg7d9qpAOMr1#D zR`HAADAP8>`c&iMH!JjexB5Qv7dVc%$i1Pv3BZ>&+^?CN?1i+yAQy2#l4c zL-AqG>T?!-)upJFJI3yeGk_31jl9kW=7P?Yl~%X!FE%b8vYl0ntg$|&x*|0kc6JS}aCJ2Qk>TyJ@ z1R9jKfBg59*Zelj{V{pwbmHDD80kf+)P0Yi2QWu%Fn#JN7BRIW7dx~9EtcWlAAB&p zD4z+tfBypFbBxXISWU$;3LDGunof6Ti#O`kY3YFmbM*cz&eW{pL(_5{s98Ve8?+K4 zHhV-6ntJ@FKj35fuh@UYXKT0Hw#Q2W)x_`hrky1qSTDFUDLb^rEmrt`jEY@>t596; z7o~JEBU{*iX6yf6EET9z?dLAp%Dta{0J}Un@)l00!|66G)s*j#_(sJtLwP|`>;?`a znfM>9oY1a<;UO9E>f;i%7>su#JiMD@rOY$O#`4jVaT)Rw**L+-o!`#cvz$Q<<0YQN z{lBzHg(k6)l5uOLlQ?HLUmU&!nD>ZnlGW(wRul#t9F;J|Nu5YQUD|IU-CIZ9G}&m(W4NJWpMzY+R~MBc1_D zG2;;s{#^;kPNIj4EInqC%O348WqV!`Dl0l>E$Gs$c zCU>qoS%-A{T}-`3lWN{>z(KJ|^O%cW%XrPUSR>0tsbnq-N4id#I@yntwcgbz)kACR z6&hBKD`*}9$c}3N;)p9sk;;hvnuN#kA|A+{_pN~*02WyIz6-!<=vI>f`RWP4MAR)dQ9lKwjk0gExXTw3xPMvDHXvZ{sC0NgLFe7v*M>~Zo(0ykzfwZp2UnU? zu@Yaf2J(|IWckoj z9kIIk?k0;GeYpyK9T%ugATb5->a@}(rptdJ&X1v@nWqZ7GTT2dD5z)Wa*nAKeJ-YFZZQakc8sB*cq>086L77E>PRE`aJdK4f zL(fR)sZH_2jdLPYUX38vUe9}xBV?}jL4nEnL#2PnL_Y_QfOC^FGM`(@5Y4DOwewcx z!%h_^^79{8yd@JIMN}G{njD@3MCL6Unb~4DAa1E*kLL)b&W@eH4xxm@9j4|`Oi}jN zSVO%*I_&YrrG}5zUA+~4_wBG$W8#T?V#j-ul}u-S>HG_P8?KNnTDpX^`=!?WW-WgY z!b5egfbK_LcP=R^ut?gI_Lmi+6j3@z7|#@YE@(*ZvS_}Aj^?`PE>?(?$eAdICGfi6HwtLZHqCTd{V?hJ4!w9gM zqxzmC<;=AAUAE~_tZBkc(Bs*rBC}~n&lqj(0g>7{vv$8pBm74*!43@cUO2f{6YMvH z3i%YU8i_b)*vifh!1Ho!oSa01zTSKGp889S?j;5;8HQU=6F<6K>0bpr^?eH&%r@zY z9k|xxuwtBVd#l>9HRHCOX$46vtO@*jK5@)4j@~vlPjxU2WH7@NV~E z9GBo)g?(a=>_8pPvKF7n-i_fx)NAKM^Y&hEAHpryKyowPvnu~C*LMAuUYewE-L85? zzu`!C$T(l+soCRSRPG05B8G3o6=T>KDsJwg=#b*|7^gIPlx%Nr1tBFbqeeFty$#OJ zLZY94I+~C0r9@sftuX$~RMSHSFhGCoS9nN9BN68QSt0Qq@RVmYj^; ze?AnhJiYR3Gy&Ox_ecu7P2D2U-}NmWt*WnIuMCXz34D}^qvN-H`%0S79r1E;fWPxp zm(Q$)heEA*nkR@B?k$r&-@v8MA*Ew_uR$H;_PSXJp*}(PjNOYyvU<(UuwsSk`hVCh z1-V_?mDa;#NIUr&DQJ||sbk8E8X(3dHt!E+fACU%3Kw$!D>B#4FJhT&ZZ)q2{o2@o z8lG_nN}O1~)}f;>lYpe~&$a(-kw{%&i0%e5SXwDSCg~3e9i`Fu2c$_(YEOQDp&+jd zJ?pUp!kZNwqSK;b2TzC?p&jf>OjrB(>E2gQ_n2v*B2-sEE(zJr8o{HJ2#=8inQN38 zBU1ibPXHuuno@A^gBWNgM3RQ4GI?uPxZ{nkeNVyKM5#JIckiv&6>xBa0((BDmVpSC zh`4x`VSBUBG8Z}JaTLkd0GFU}3lIhS^U;fHLk>x5*@`ve$dj;a*&PGh9UTY`AwoxTTba*3-5Q4`s+eU!UM^8$dY& zx%F$;kUSvcuxhiA7{{qngyBPr9&dIhn~nQIr==2*~&{mU^PxCwrUVmt?mlJa!I zuFO$d0=qEN>w%BEEbALs5v3+9!AZXwQ0cc=RO@nhSqlPi=>L&^R6cC~e(&0;8P}O< z#WM3m&5NbBH*gOwN!_;%>s@zF!JGpkQ!-|8UVY)Gj+ZZ<@Az~`54z>yDUA5@q!IIh=; zuSHD)h;X5G^2|xX4f)#}qOQ5r^Iv;5*OTla`IamIJTE#=)2+8Y`RnAp4M4hHdtI*f z-vj^3_0;=rEht_CBBjD8>SU9l-kgC0ItU9oJT0k{DI-xpE+l$$|7hsx9558pXZ>B4 z$Tzi2m3WnwXg*Ncv%DO5sm(zCUL)Cz+jv<%k41|~PpltD-aEUj8t7us^|fFsrbb;d zxSR>FAo?bR54$kkJ`efUj0+U^IPEGvALRs$2s*Gl9`U>U} ztzoO4F&y-}MMH~EepI_I)SGkEd75{H!WH`>Htewk^Dtq-0DwKYQKbh*}$ib|NQ-S zseVh1T5IUz|2-_RK5R;P@&OOmYKnodVD9zIwl3SeLv^&;f-~LUHF@Jtu9v*1(qtns zNd8-kwR<&@8hZRwCTwTdh1*S<{bGfX6ND}@uu8NohRyHGki*nMN62xROwgW@rVnm3 z!Eo~Q8-GWNG=u-jX7fEaCTd$&*}y8?fu;~5vA^=Mv%IeVmgK`jwtKNB5c>L~ z@vo{63Upo-3}>BQ`-161t=a$Elf)0)c) zB7kKfCBu)tdJR;8ZjY~6hVJVoX+e`;zuj;uc%oY=?J6v%rW~77@oX4Fi!iF@*bO>Z z=$q%uoAt*l%^xCIG+e<(7a(m6MA)N7y>6HVtZ0&NVB;1Bj2>=*Vm;)uqk!?4s=$_q zB(P6^?rp(P+HLte?n+<(f)m;)a6IA!vtfg2$C6|Nq}wbzHJiMzhd z(8|5xs-{+qOfP@)iiqa2D>q422dra{g%)12>vKZSd?;6Qou;-1E}g}G;dwe8DY6aL zu<4Z3tv#f;nMRY(DMkPLY5oV?HQ4GdUlKTE=1(}|YXv~oeI=|Nq6_=VEEy!Ez2CDV zP49-8yUIBMMwtEcM{bjZx|&$2*d#3g6L&;xeBgnbg``W5hG;wof{xeN&Te>?El3qI zt4ZIrd|Bq30OA}#)8@E<%l1`Qi{BZwoGl6coA6_pmH}OiHrU0D^Y*ANJm8o3YVwDa3b^Cjrw2ggk#~MD48|9L@>!SPA&AF7$pCI$vl)1HG&* zc5+mrFqf)`L2bp%{KddqY0eb9IKP^a)tY2qbNa=`iR}QN>{+=HC-Ala!hNX)V>O@O zA+_{M|2gONhc_3OmLqrKFbwjZnLzbn*dGHedsgTzgn+xTOEnszU+{&I%L>5itae^& zrlac)6{5E8>dt<*^xqh_9GC)F{92#mc{8{-c)@51VAAI5?OJ=U(h=?E>o@X1VUdCm zh@GZaw`GcLml#m$zkR@GVO<%KvI?vTnBM7hBf6*4+T7`LPy1-v;g!R<4qnnRcL)u7QmxI@b;f}<`8E8AzJh{|@D})l_*0`I1h||*>_><@RuX1h9O~^x zi&Qfl@E(|r?|wC~Do8DOw(BnmxEvR?v|~~7Ut0*SAkXT52Du!<0^K3DA>WU}qoV%- z)4rMmjw69PJsI3|^-EkV(r5d)$B1qm>wvIK*$|&xvB25{?rz?aMc7}x`d=#%P@4PM zv(u|F912ZejgZ^|CP%DlfVB>{<=?v;fv?lXJgVCkOv@4U?wAo82u?1BdxBRfI9RuO z0=(|O{Yqj2h>9v{32J~Z?H#Br38E()5GnPTo@K!GN7U1*J)vEJrP z99ZLH68UO%{HD+|2{2CF!^O=@IB%YZCejlr`91yZ?j>N)hEfHpO34w~T+qu+kWrHI zuc_>GT`>vV%)R`1tLvw7{|(@aVc0PI09+(`M*V+!d+)F&&+mU+-?Y+J6s=NFBvetU zAR@{pBq}N*tBL~3h(eJW5Sd}tQbA=zL^dQ2hHMcCD}ag+Wkp#50*S0dMhGN82qDS$ zjst5y_4nt`i_0rl@;vuE=iFz!&bj6FC)C&Sv;WxMngw#&o#ys?yqfg}J-xEL{sZdx zJXR-p#9xP<;+5CNmC~lIcmWM4bj-vAs)K> zxahL2L6cf#El;Q4Nczug7b8m{y{5(us@pgax0gC@1r4`#S1a$J_#mgAezN68tDEJ{ zwBT1yN;fhebSwj$)gL`+8#Zpfk6D4~h0^djM=VD}Cq2-+%;Wt*sTP@7-69Z&8wB-d z&kmh7)>_lBMQ`DC14yi#ZCBf^ z!v||K4z}V%g5>9Ul_)e%RUc21@XfIUc*~wp4s7&p@)^@*f2YU7gI977bay5eXBnedxN^iSAm^fl8@n>l1d~)5LoIu)U5sCWQ`U!rLE@GxYqD zff&pNsUQ8#@IzNUsMREeM$9I!C(4J8oh-a)wuKy3sQtto6DIhcrvNexp(qtvwd(n; zyNIhL4%CQp8+@~_5@If|91K|*^NBqvE(s{>mvzwI6S6L?zjzBc#~24c$Xo!zkfEVO z@PO~_A6Y7BM3_20wj8>0?cj>b^%!;-Kk8KbKy1&_vGk?&Ta@PCZ6-c6V>X;-+k=8B zws_wjW%}za>>YXUK#{Qnev@1ye&H*UQjhE@P$}drX-#tts{RVdvB;b47dHZGCrByS zJ5<9-07}`#(XHDQ0lZda?#I_$inI*NZ&IVnJsxFxeVd$%$NDSfir2+SUO7fUt@j-Q zZYPvVYlI>VMYI>|e(yEdjd1I6eMtX~{)3}D;e6?7xzRu+7rmQ-oaQXZv1m32~7{C@b_=Y^wmQLBWvLggz9^(hM5_pna1E25yxMEvX**i262&O3q&gb_e6P0?; zHvWK=*Z~EYIJBF3P*^(~QnxPub&UUI*n_aEC9sG+D{s7uqA^{|OR;|MXMYrx;!N`d z`HLMEY3~67AR(viX~*}o?)^vOus81pbL3knNtH^>^uTt9s~6BRZ* za(-g#8w(}b(FPCh_8Nh+lv@k1YWFOk%P`EA8ce01FVK2(cD;+VV-<*li((#0jG-h%p6>JgEsSnX|_ z8)2vH(bqBD-xmoPccsJ^Tc3sozjj~qmPR&*B(K-aCE)Gd^M|g@A4f{>z1Xg6T7E@4=C^<;>gy_15J@R(d-igy%$_$i zehUTjUpdzbb?+{ZZGFsHdf8>E%K@k+TJXcQJL>^dZ`&Urn?%LpV1VH4gx00U>MnyV zR}RSXAzNHnhxdI)%mDYh9GHsnj`J7)9(^xp!^Ow_5?$N2oVn(vwJ;)Oni|J{FY`v( zs%!`({1qSGkY3*F^rYds{$FDtx!*o*n>UQ`?Pn=ek7PXBkmYIF@yEZHoUl@aupfZn^pVY*MqQ9yjo|r4FXvS%0pgL z(tKVt7d9Q3)m=<7Ih~xhYyB^(oP4!R(kmaC=$S>Iff$qszE?9Co9+rqV_K)uJAUm9 zA=V#souro_caAKBwA1e?m2KkaJ%14F9jOCuwXX*5V7!UQ-rG*fy>4#vBI5=~J5*1~ z;(pd((g6qwYhY+rB`1{A^&|Om#+7?I0`9>+f>yHr*y3Yr30ld)Ob{Qkn0UWk+(LB+E>@qf+-FfFON>%Xg!du7cBS- zRG)au5UJBKwHzN0PFw%E3-u0}gbIRwKEMFoF~e}nZO>OH`V$z}wkWp08UHsE#D^>%fiAujoE+0J%T|+BG$pEi*z8m54e-O z;`5tX*3LuCMb#(&406CQ2Sbl^wK~GlJEDEby&$I3t=)JHWNA9Wcc18m64(!FIfwjK z6!&2L+`}BJ6$rb^RkHR_%b52yG(oatUc%|gZg$tHYd)C(JNUC~a@Lr-;8G2M>7OFU zX%tI$Y_Ss|1TBmE@9$AqvzI(}kHQy37#;%GGdN20cFzUv+BKj|<4#u_m-p%x9|bVo zYP*fDGst*)#qO>?=|zLjRDLOiCb~a<_eY@8=X!uQ+%e9B?cFYCW%}6C2LfcXW4BYU z46#w`lP}Qv-dePJaq@k|Ce=3|%jAh5LXusw$N^Qc zM;zujhoHEta!~KoPb(%OZ8x)H~%2% zIjh~quRIZTN}Iu|`Kz?vm%DwRO??f{yZ1y+q$tjBe7~r?5dbou_G}MWx5W=Q_mHvS zDQwC?qQWBwjmaG(-RUxLy&Z5rgQOD|8&D)BMuqDB<%bw~trgUQ? z{zO4sr&V2rf$lBS^lNiGj z#({y_T{sca?t^#W4#^P{0pMYi*AABETYCLE+H9TxKqq@vX1y=q9E!Rh4_Q+q)YN^K zv^6TGO%KYn9?WDsGRDHnvdsy))|F>_Y7U_$Jz_8F`+|s^b%p(;=4=;dQ@aibvsKe~ zA?N=*s(@S{prY5*WK7C3a3*JE z9)133Y&ljP9Y-F_&FHKZ73=)*=!Jf*HHVBP<{X+t{aaL?7X}m)N!&=4{)1WK7A&{0nw|C-%cs%HDWaPlXB=NG~qy z*-iYh+Xzv4Il4Q)Yqw49nv&<|uxjr0(7+{a^71LZXU^XcxcA8i*+&<88NC!0MvdaT$}7ju6~3Nb=ERH8V8<+5i1si=6hiIF_Tm_T_G{E#QU2_P^n*ekNPZr;52mBLK<#v0TW`zwu$0 zu4Fq8CIELZd#fu(c1b18U(cXRmA>b%ipPIZ-vYpQlCf2>F)f0?O|w06>WrA902GP2 z7%`asmO=}x&J5Ec4U9KkS2V}Ag5XL|A7M=FO8T!Kwg@2c*AGOZ;$Hb=fiU9-C&dRR z@t6PD_~Yr|CghHP2+xn3D`I(xEzx8VpO2C@(+O4FNrt#5P{>g9gL1qAl z2qxKh@%9bx>(xqD8cTPr>xS3Vw+ALr1{ z{uu!F83~FCDY(qlHT~2-c!Z|Yg-eeID)XnT{V%_K7~ZpT#%cuZ8_mn3Akxx=f2b>8 zx=*76tVVbq4dvqU+qWjrUCuz*h-yk=M5QghZ2Omi&`_@r5#RcQt2q)yQ~2*mBL7_* zcy$p$!I_`CaN!E^7ic=}m{p1WrTsTh=P{It#3wi8m#r6l0yEHKL18faDe&g1`{TOud zN@aL>*o2Hp$Ar%%&K&--CZ9fSRRqsLP>}80^F{Qv@Akvr{_h|5wH1vUos2LBQQ90N zIyTm6!2ka~1-@wsU}d5Nu_rN6H*UCgqdsJO{r7`fz?P1~DeT&%!2&rEt{980C(iue zKbXatgCFmj{?@>9UC9a_pkU+3$Zj>Yc$&_CwX$_wmfHdi=PqddEG30ZrJIn={-dw0 zC%7iS8oa&@6?c#E?MGl5LfCgqe$2f*xacl37gb;9Ov_G5nn zT)x=u^WTf-{Lx3Kq;ye%2+~RZYs+r4@@^j+L)qHyQ255CQeOwqxW*bp0|SF?-x{Z7 zy$MJ+kOJujpE>`(`-=T49`KDpEBz$01^@^D_wNBL*MY_TY*38(8+)Dq__&C@|ARhn zKMcuvxCsT3moM=HZ2JvbiZA(>4EXQo>JwEJhzh$DdZ31&FfFL%_KhANKD_hMg8%Pk zUKczXqAsdrsY?Snq7w;WS7dM*4T}q?>UmgM9-18bU zX0HgnnDx|0#l4YULJKAvTKoIf|5=}zzFp@L%&bxQ^ZO^B+0nnYNkaidUQ#nih@lR>y`S??q~FMRS)mCqzB%VtvN zY0Iqb-Q7l?{?1`qbfH-2uYPjfI_Eb`VS*%>o`DBG|GL{q$=nHnf<3XAgq5ccSFgUd zJ1->BJ+Qvg6|wzbHE8^?&!k!#j`5}4tV5Rd!(4&-CDxyK7T(Re@|@H7KVP>6uR}OP zUJec2PT(@8&{LJ&U0;o>xJ3twK1{5I_3D&BnQHj2dj~06|2lC=f9^nUN z;+`*~cFMPHWaotPy8a}IXB~)0BSN*H$zKMaSafXz(ZGXD$*y9BqXair??_ za~kR$|9UA8h{bx<4JkXwIC&Wi`thN!n#=`Fj@I)`r#Y>u^u`Tek7n8#BRP{%Lug@N z9+DvH)$n>b%spSwo0SMJIW4Q{ zVM4C-f2&M^NQJ?`eq$9JA#ib!^8D7!z#d-CDIPLwL)5;%ThOV94U1m{^_br zc9DN3P4G6?wVUn8rXCNuIyP9JoQ+K?aq%Pf<<|ub7^3jSethEo@HY&i`QIzkb?fYq@l;{S)Ds+26`OM zFI>;&P(!`k3dqIwb$j*SFym=G6Up|OZc~%%g_&F@zh%kwVf%|*8fY~(U4Pw(w!Qk9 z)w19h#TWtcxOi|>ovAlp?z(kOWo>+_}(Ne+8MeCa=f z2-k<#sf}mOzlc`N1&Ihru;rz)@+*u1OXqAtgX)Ev{dGJ_6nFmu-~oAG1ia*8W`lge zpVBJ!*WPdfru*V^OoiIQHme&1j|(DP^y{bdbBfs9z8jWz1P2p&2WP3I6VUlMYm1k}xx$p&{CSL9poH z_5nXG>nWZ9s% z-sFT7?gv*q9A;6E1&7NwNsR%V@cz2+5v)c9f_8pf_=t9E}2oO{&e6Olh7&`uu{i_n}XAyF@MGq=J-+aFt zIje>m=T@s?kb#u9auc&5&cjm%QSjvOnPpiobK_(|_askk8qX9&b!~G-qy%MNy*O#< zcP)Psby}C(UDmZH8IdmjAXu`nC3Y>HjwNMnG1FDB^B&)T`k^29V~JS{6~0i;?!0rE zC^yfq_GvJ4#s+%}QY)#bhWQ1Vp9%LDUl40Axuz4989NaR64ypGbaR>66L4bOsdO#* zmEjwKMfO;hKa9y6v*_q(a33t@2F#?-R83tkpCvRg>{~$+P}b&0%~^+u)xW_z)P~Gk zN(^iZ48>vbv<_a){NNRhHdjtH!mqu3;@%5)d9*Y$d(;f7pXuFuV z-dehJEVwD^z(~L?Y_RW}1lQ4K(}pQ3^BqBuP{Q1kY?lr$OYYK?rbK|$CnY@GtTo&R z977!8->bqX4^PI?4->`x%+5E~WS^0bI|yaIkMj+zcO-N`U1G7(Sajl0lY_=w1YbO- zAt1j+xyx})z_^B1HoK!?KflblWTiqRlS2;+YddGX-#*+kkphm94#EzseW3TsQFOqZ zlNFtCn`|FCROD)$p4j1NAGptNGRRht3N~V_L_^cl*zqS$PX+lF*S~bv62fXI#j|#l z%Jm*`S38K&ccyH%OAn0G8VocHi(#uSTS2~~Q^(8eQIB^ZBug{-?Uj_FnG3V)e?ml; zm5=(*l__R~T&bBIQ8u=@L11ob>`zj@OAqIT){L~svN@wrM!5e(s<4hYL8nV9zU1~d zEUx|Y`}JXMm3iPqfus?pHjxxMX5;9;hs@Z;>BAPnU9Jw`kTwP61|ZNUs}Xo$FA|0+M1*X1fWis3SlX6iqf8GMt3We}ecPbc@^dZ7qicw=FRL-+T*M+nS1_fsMUQ!mOD;&%WfTtv> zBDl2zewdK|*UH*^;I5D;BY~pW{L-`6Pc}bdM;#s!gUnq{qSpL{_)G>9B^Pz9Fw#-< zP(nsJ49l$^Mi~ZVHM09Uxqn3q62oWWYG;V#JBDf|A^z_l<24q9d~)f;u%W!UtQN9G z`H`dS6cJWpM9&Ctg+w;5@nc*^);PXH0pf-9Z4CaspomN=BHxL+-!$wlae>|r_<}Dp zZHH=a=fWH{e7x^-M;|t!z=voCI9Fdq@U8WCkg?d1$!e~q+I!02m5PAiiX9jNBR?oJ zO*~rdYAmv7R3INblG@1uS&fi}=ev#*;dJUANPt(gPle=p??8JzIxx0@7j}69HllW*em-r5Z*2r0 zZx<$c@`gC@vBa6Vx=FEpRvWX?{GQJu(hOGg?Xzf}cFQCc{*?_t9{#I9i{vwRZ*O@MW4E_z2UE0@$` z!Vh%75led5Up^oe?A*JvDMd# zq4Ec-QGsD>cP$abwUHfHJU+WJ6U@tQx515_uj((hLp7l!YQ#5&O+Gt4zNPc=TSsHD zE}0jLGd`Z|#U5#U?>D!pOVw72_3=Rvk|H={Qy$3_qQg-Rf{TS2vFy@l(@b4`kbXVH zzEn+`3uRf3mPpM!vd*{;QGPOtQ@{p4m|xId4_%iz6g1!AoL#B!9J)A-LZ#1Jqaclg zh=TE1Xyhww2&?I6MANvkWe%BHqbhko6+f0A5*JuHa(mPoCE!X|h!2I^QtPE{EtgYA zET>hX=vKCVju(Ayo^Ou=O{_dLF6pqzu2fl9?Ok-cBy>(pLhaXZVZ+Sz;koH>+5ZJdQ9o2ExnGJ3|lP{?4 zNG=M1#3%2N-xGM_nZmF;;Rh)4h)}+1)QTLT^v5~-TSM|A1_!+L7a4jhGvBGr%IkR^ zn+$35Mn$inVcfxYt5#p0DbU%N{p5Sg=F`bhbiO?^`ASEz7LeT+HxkX^Q-7(yFU{@R zV!q+^NDQs-EHsid$CtT6I8re_+23~z+A$o*4Cl88LAjtJb4r4P<_jXLTW>RG08sM-M&Xw2z&S7dG^|IJ!U=DYYV~))f;HgIM|sq=KYJW zhl+dywPeF)$@4(l_Bd1TFisTbn?R|PuJXD0_I2#u{!*6ix{{r+bDM~Bk?Ax0i!8mS z+O>pa{4(!lm$6MyCE(0o-W%3@k7~W$$PQdl#6r?4_j=htbJbL1gig<3oGAkeLgM_W zn55cOK5llU7{8noQcJ*u3m=c-GlwQ$r(@hX*o&0`KF}lR;w9w$V`)g=OcMU z9LI14z%y(!uC%9hT@G(mJ0dy(*k{&w+a3+MGx~euY@l3vB?-RMD*@;)U2!gkVyi0k z=>ohmQDnC2W=G{h?_KFga|Oy55I^!}xy5yzb{uF*y&x#8fDAu0dkeZh_Hc!yR!A^w zVkZ|iQtzM7yTvXM%_yq=KNh}Wg_OYTf zSf7g<#H|XgQ)jpVLg=3=8~?`2v(MV=%Q+jAAR30XGRFt61;Tv$e4+-^^enBh;g^hg zKz$zD>E)3vC;!V$$sI2jVh_J0Z)v1g*`e)olW^aA=|ICdLz0~s{f5xT{j4@Nur>N; zh%YO6^JS_mVv{CAKAeS`2v(DzJoR%Q-oC$sIMp~gF){vQLT8t7TN2ou=I-$n+TnKj zZA*cI&{6uR?pCX}4IDwY0gNUtZw7Y>gpi{M9p7_9xa);}ucj8JpTu(mZeyb*l~yQC zJjdrRPE0K7F_Uv*P-?o^tZ4e109jFnz1FC)J);4v5m`@n%MWI#jenb)JrmN+9Q zV(G(RCq3no(G|A!&{0p!mp$z# zAO`fl5gwt*_$+#KdF*WYur59pq8P)Wor+0NT;^4j!VHho)(@p+;J zD{gIjBU_O*3$^4BH0g5{WyCsdZ03-?T1FgWfxMAuC2ddgS+);)Nm7oH6;3jjD>pR+ z;KfJxFNc@{6~Do&vZAlJuFp%49da!y6GfR}{=QUOEizF#-A1TA%lMM|eH#dso+77ZwxH=`wTUI7P`UXItY15G-TE@ubighXqlrY7yE z&CSc+-gjh_V@C()aAXU@8@NTfj9nDJ*}8 z8zp2)e`4B4bszFFq%xFU6d+lBw#o?{7^Q!Ok{1~a!5{~||2O2EoY+v6XKQka_7fI9 zxMrT^&UWKAq9(yzgcLmO{Obn#EOgzoQdV_c8u2=o4E=){>QAZByUlpx_HV`W6NogrY{aDX zEsDK&*h-nfN!;?ErwpY_PIx>3N1G&l?XsyBH4#A%j4RCyn_lBRkV>Zyt(E9lb+2ti z0CkUp24xle%?KNqsVB|lMAo55KiYKFFr8iDnC&6)&S7?kK{xOwRW1ug&0*)f(PqHr56)y;4d^H6!G7H= z)*S)~h`!D-R&BVbO9*@Q7 zT_8E}-CS)5XXvkazT|qge?PK8i}vh|=DH?C+hcx@d)KwVuwET$`gLFONKh>Hpj!B! zzeY9#ry|QIZp#1p2`if9|7=kUrmk#SdlX(15xQVnAPHH)faS7OJA(Auv_tHD@XQo8 ze5S?SQ;8oYIUlSZE;BM_LF|w};zACfs=jLMOCsY83#@>NkQt5=WRBL&`{)&vI_}|I zpxcMMnS~Z#fD%XYnSN8GN?vf$g<8!}c6%l21c!MZD%5VRvmlMKHj0Y@1F13|ZrjA5 zn@@*cS$0mg=R%io%xZ8Ci1XGN_EPtwSxO*3w(=sM;p;oMLt6DhntCvs6>6YTbP97U_#Pp=ff zlvOU4m-iq=-Yeo>RZKb`98b9AFo~rR+X0ViRJ%D?9~xYb1;)UyUJob-gYDg_XLTUXgVz&ZlTAP%ICJ;}6Fu^gns%Yj?2heQD$5{i~xc%vK_%6ZS( zfswBCL`&*S5A-b0QNwt3FQ%>?tIv_tU$LGr;mGVJY^n|h=R%7R6vME9>y`+-AQ-qk z@cl6mGUS!DHsXE4e9_9k|j+fPr#b!5Jl2|?PQD!S*cAQ-nyw1(A zs}lA`yaHi|vkUWR_8Qo_A)p8t2eBCeU4Ok`FHN(zp=voYcbXZaP^Q9#YM@y`9GtE- ztc^HpH`F%YL!#yYFPw=p#>X47j_^b~a$JRY7M=KNj_qyd@FygqSLR6vRv5a>43^KC z`iUc_Tr|oH81xID2oTgo(Y=NGaZ?FVl+y2m@%E)dL30wzG6G^o|L-+OzXe0!doUwb z)(+?Bbpj2_9KLl%)sw`0aCD+CKfwlXiLMM%Ewr!G6#0+Di>*q0S$Xs^4oQPFlokh{ zi&+uq438Zq%K-!UrB4hY);*N5P?>EG%Z#t*&qS&7ADOc+)h_i(+g&^Fy5K9(V;HkN zq2?D##GLNSsj%J31*6eU}$sRoUIlOk>q|p$Ht;`5Vjy&IM7WAkmUTiL?3CGb# z>uJF0uv{!QQVJtf#D>H-*Uh`>^6)psV7GQNaowNA`B#E_e{&AF8U;Op^uUsOdZGz|bo<%Dx?=)7wI z`>Er?w5$c?Kqx%&@l>x|2%_|^UgQE2YtTynxd891(Wdk8gN)IemNKkph(xaHn|eLhKVHUjw)dy)ek}noL zjdLI~z#*t9`G;c#>irNsW4><-u{>DdS1L0Y7Zzq~AHO4_AH=3~-#gRVQj-rfu$EW) zyi8E^JB_dU9AG#vG_r0~f+Y&)2xEq?2&2|zODS7H<|q8Gs6!@KIS5G(b z^f@c7o%gh;t^p@PODm>&$BU*M^_D~IfL%{FCs;0t91%F2j@3$(e3(h)&>VB6w)sAa zDRc;g$p5l*b#fkmOcz&xF6xUW^7Gxj%={3&mBGBe5kByCS(J>Lw-Plxw={4}D+e=y zJJnPS61E@S;JhB*wHUgoDeBP33P)xKT=#t*2ym^R7)8TdBNmrtUp$DUH*p4{9TmqI z{iCJPq^Xw_=j`Wbp`r?+^l8r%c$nbPi%?+th1-QX!VCnbZqr3dVKSx5(7`TRQfF(l z)cd*Gp=;#237~Mblz5kn^2UZFbGA|nEHl0w<0xh7Y)JU_gD7AZc#;?4u4<=Z%I=TG zj5`4%EjBi`XkDgZflM(0HO;1nClgU|Qtb^DK(xyIriCr3+Z(#DurlApl8Rb+a+Eyc z?`^-_`9kBC%9~OA49@wE5F=8_qP9Pj`9<}@Q+42E<*jU&d#%x5Te(l=!m@?^26;X4 zl>y`kCx%V8!EB%|^5=jb|FkoaRN4RhzLS~@`Y0-+r_Q6Fg-=_#%FJHk56F(mw~rWz zdH13mM>U0NtIGQF`g6s@sKJ1STl}-hN88Cbjylvz$K5MX;G9lsN`LPXLV zimVxM@6PlO0mC`P%y>*lkW1PbwiDzIp(2JcfJOQLG0fBN1VW~T@SXL8RcVn0zcxOI zs}p9e&2#r4!^&+Y)n2stB&s#Csg@oS^_Pax6UD)hQ=;IB-e}?aEGbZrDenmiZ(UYN1BPk3_p=Dfg8Ksg(qg!K(@+vbB)Rw+E`)nq{puTw2~m`f15dFG zq^_VS^2W(3L?Vx7Gh+tAKYrbi$~;%ZviIA&0Ho?OF=AX|M(~C2<6|4-(yh`7zY1$) zJ^zmMIzs(5F?kB!@a5cC4J8;(WUYZL~9p!=+?(|+7eQ6yw}k<9D@AvfW< zMoOk$z&M>hijMB=EwVvRIuiT_fUsH~j=JG`CDdSfnw^FZghdad)yq!MM*_*da7j;#ZM#=xRitc9b-%Rkz63-cQ z{u(+lZi|9l>ipC9#TG(#7gkRtQ=1joxT-_$Had$^qPMWuCh&5BvnMzI^3d5H5v8v( z!QCtt*b6SE&NNT>GHjynC?Tu0**+~2+_?U)`2^FrkMgKX;$1PS&RN?IJIj2v_7*x( z^ih~h`rbM{wVqy5j<|gT^aitZ``R&jv1*~)`ktIb1#yvefc@G}!Y|qpqebxI%&MeL=Y9-@jziN0u{&Ju_0ueqRNjLtA z(UD41I0jg1!-Y1VuNI9g_)VGk)uT>R=qIcYfDPdee*NeLx9~v9xir0im+$ZwEYj2- z?LBX070?D$v~k8)3yUb|nGGQnm-}$uZo>dLPujo$R0LCUTtE>v5(5}UvE;9M6esxy z)=&Mmkr++J5t0~kP^ec+dK2b<`V9$mS-`arC2YaI}O3=DSf1?i-7aDM} zI>=&qk$`2ZLZpv;Y2sWzqrdB3QprF{J)&Hxh}>rl)WicDh#bGZS?E@|KSP|Ie4w6ik#b|j_Q+vJKOx7nC8?mK}+mzpEc?=|l zH*3y(nc_T2^-5!@1$SVf)Q6iNGSBIhn(As+A$Q{BYCGfXf-idXhxuA!%rkX00KDwk zFp9lKA13u=^jS3>WVe~S_j+}R&3pY)*jDgn)FFg3sCp5JZiB6=;;gG!N1w>9(5!pXGHXWA@@bKB$H=<`7A7Z;I z*IVUq*IUvq>waoA+)hn4Zrj|8rh-m$Wm0yhYO*vuBS35uYmqjoEr;B$d_kWTmTJ47LOfYD6+gy*c3VySMEB6gPub7 zyr0rSnoem!D4$nGaCM^&eW-i?l^3EF@@~7GUg<{7bbi&~K??I-N6$u*B@3bGVeVkH z54r=8+L}79#JtQyFLJ3e;$AaDkDdF|)03aZDfk)3Fp0g)>wtc2%JIsMveA^C@E$j@ZW^XPp-f(@w}k(>+fg zzS4a!fb9SA`!&B+j^G%jtijnGpIW(HR_Pj^4;-NK^fsSQ4{0wqvP1J0pSAdqai1Fe z{XrTF$FRsX1KPR`>eFbCd|wQuVgc^!te>Bsn~TdS087+>ihpe7{!?&Cj50BN8|KUN zO&_$R<>eq{;-^J=3`LO_#TtE_F`qiRG>!_b8`k+WlEk_%68O_sN+zF0F1SYF<9C0y z*rDt#Y|?>WgN7=EX{`=n1lVOrvSX`%X~iRh z3!#-ry-(juX+x<9KnVpMbkTYFc|D!_>vH$?bgw)GYQpLg==_5fI##E~8v6$^Z&)1_ zei(&B+CebHG>z8PJwCky!&EMUqE*O9^5?EjT9S)G21Jqi}QI2|%my*gT{oPF&nXA<%Y3NlxTc^t4(UJ(#! z+Q9jNm0cZ!cOzTGGfrQ&M9P_i$vG!FeE-29rOHIdK+o~jZPSC6xKnUL|DY;UBocxl zkx02HHf`H#^C$pGp6JS@|C=CF>HlU^M1BG9sV}V>N&d_{&j4t;vxRN#2XpS&@rCGG zKhZc6s286fZ3Bd1mj(+B0(^RvwmM2hFLD@QISC@YBh!7A4#ziq5pZ7!V+1IjuHz_= ze$Q2LqI4EaDoqo45iIdnL+~00-2plT)BVC9EoH&x7HMZJ9KXD8)B4XrY6qt;=7LyF z5)KJ&S^LZ@A_hRh*#Dr%$6)m>5^~Hk(<+gbtW_ysi?X*$2777&5*&bfR=m31iB2Cj z48*7`Fw`ivuEFX?|a=Vu63=o2~(7p#KIuLKtMpil9m!zMnJd^ML@U*LPrJm zl-hcy%|*e%_;39a2Ghwh5tKzA zy^eX9gA<4F;VU*0ovWDx%0yr|y1KKPcw6O@iWm4c5yrcimFP4B?SWXFyGYR=8|~)3 z4*Glj2Dn$VZW8=J!4#_x2w3#;)Sx zG=^;DH+0W;5ZQ9z@X4X@VdX+4Q>oXLCPEm)uVO#&0Bb(8LhgW*wvC29mRug=H=T_13>|#LovkdT#N;Z`3bgfJX7xWWj#jF7f%MUV?f-y-%^7%u>H4Dq94Yc^k)_tkQpm{ z?Tt#F=y-a1rf~Z8+f}>ra}Op5_25~97(ANV)fLIE6Pb!=Cu6(d%LnXrZI2fpe85Hr zy$xW92}G{LMi9jg5TQ$9CINT2mAeOsrzA0%RaOvF8#;;$q|<`DbiDZeV=wrRrJGD5 z=VgN(Tt34qi9Rg`gQ)mBXiGygC44~Tv| z$q16Q$Flvb=l&pb@E*(%Tj&!@Alb{{H-nEE#hHmZHH4dr8(MMMN&T zAM^T~Odc}rYr2>@DNe^Hya5?vLMlo;=(NUp$TS_+A{uJ%ukw}E9+k)X8RlvPW+&xOWe1#D99`D( z2O1$I?VO9#_O%X~b%?VT0*@6s+4siX-Mq1dPzS!Oeoy{B@tyt`+ApkA%+r}urc1JG z{s4)=7n+Y}(VvM5yoh3nRure9*rFK4Wy0CP@kD?AR-8P@Qo{KK>SNrugkM9xa(>C4HVO~PsN-HQ;VrT4 zzuG6C@sqli)FXZ;ZK5Mm5Lj4w~r!U@}haOAq)GZZAKSkXSE zRiN2@f`^BNr-x@jgMpV!n?z5l)Kof|f0|k>QImJB9AD@$S~GezioYSY!A}%rE^`2- z9L}q*skWN-m}Z}*rfi6d!jTMvRjU>$7rk0f8n!k=F~c>3@7pXREwe2zc5^47sXkE! zsd=l_(aEaPsmT}4Y7i>tmrNJEDKRf8R}w7rd|jgum1kbO0_)JASDndq)h|nbmrFe4 zvo3HhbFP2>+B2F@X%3W`oAp6I$5*pi&qq;A?u!zIx=D*feg`;89`&73_ji^`f=z-> zf)fG*s&#o|1sssJV3F%^ne=l`YHLqx42}rSO^#hF-yvhGGix&|$e`7zXK~a7d^GEW zpn_NE@$2n?u8sIP>I15$3PN0Gu@8$)ig1gn6fL6rT{2(j3C@Y=RqUwG9W>nYd?0Ao z{GeIR)8g>_X#G6vT=Yur+~UZ9jWfl@9KR~VBY@ZPy$QByjK#;9_j87mgS{*r_8~@@ zKU65fANSCVS;i}s485;taY)%CUnE+Tq{5(Lhq=HaAm+J#9f;3XO(vDNXX%4*@ zy;i+hDI>kjy-vL)y}O#&jGFkV_%n>Q+Br$`N$W|8N!+E1^cM6V##~F@(FW6&tB9)< zLuYHD+Erx-W!9yQHUwNBxae(&Ao>cNS!p~jUq<0Y3y@5kDw~sOA-GBvCS1p8!scXr zemKj*0PNQpu!8;l1+7w zq1+X5lE`!HZQN$2bB9O|kUq$|)X_%S#$~IMr?qaluK%=XFJci!X-SQan}#DDRuOiA z6KZ}wFe^|+-9=kOvp&yIrFCGTms%#sZ+*PZ+wD~6^o|-gZZYl=mEZCkr;<7`+KdDU zZIFP$JpMd=3%4I`tHJjdGekj>THyZ6Ds zY&LCAcu#Q;l?Am$Hg-`M+v}$*8-1C=wPH4uOgL^Ub;~yWgbBe?$IImPRgUR-8~tpIkO!x5m_dbct=tS&nGHuX?(x zAIKoBHQX?23H~vhWfsu$kfDHKK=*nRV+JpuejXO7Go@RoQ&;-&BJ|U9tMG*Im#S5Do6xWhJqPI&()a~@Hno|tCYrewsN|!c?=F>`qr><15<82@t)5k9To35qQoz*g+l#K($2$B?ZlgP+b6S-LJxo8!(Hif$ajT}PCvLyk=~_52w%eMAXKM*- z?2gQ9RPN6B4qf-(i$!BnZB*5GKFV8Hj#}1LvM!w9ytZQ0`bDm0yryxJz=cxsyVQJa zvSfVpJU_l;=@HMS`!+LGt4&G4#8YlEEKDL);t^v0beuFkdE{(Mh^t+>FxbiQhBing zv4B$^#xKUJ;x6F||GuRdQD^pDce;LOoVP4`pfkJG$*3dbIO4@qW&?~m=a#5B*W=30 zWfhIt@{GFfhH3Y{Gwm-f1)E2$d{^2FJG1llUzR0C}@$k`@pN$vf z`aD^<>84JIKzQ;%VqfCewU58d?P7ty#f~JL!1MUU#g|{5KNYmIA9|bn*k0hAR$sy1I+vo_$h(rh|z!oC#7D6Qc@3sUY4Z^*@_9G!6 zgn|)}|2ak;_`H3^0Pou|e}CSKdxL-me8U0W?(dQQbM$@a`+NV{z6Sz+LwKbkCM^wo zs+c&Mnb|s7+Bv`6=9~t0+_#s~aza4Br@Vb5N-Mwo1)P5Ztg7j(DF@;+v4b!fnc5kf zF}XwRZ_k4u;LZnZLd=|v$lM_|woZKRg5-Z4!3S*L?q((@`|A*AYe8~NIYlxtJ4Z7z zE+!Tx7IGmBGBPp&M^ke?WpRmrh6CRO$t|6o?fICQ-Q3)m+}N4y94(kxd3kx6S=gA_ z*cgE$7@a(9osHZXZJj9oKFNR1BW~to;s~~P2HV+^-JaLT*v`dSkevMXLjQgJeV=CT z;Qw98*6E*V0TX1t{e_v8iG}&U=LUuf-0tO51iPErXo-U%fXskv2=Q=m2>f;Y|L>Ro zUGZNdHUBq~jf0!z-$VcP)BihE&B@GB%nkxv(^=?$>-EpzfB*Q;Kmq34ssBq9f6Mu= zy?~&FFa((YyJ$ifc;9G=fpvTa7FSROJ^?MeeIWvQ83N7UpTIUE>4uO2PYD8o2!gcu zD^+*ItvR%mQPqo^8!ya=;hy2gc_-w}VPt_a6%p^>z9l#Ic=D=3qxAh-l)SeXfq{mI zudu;%G|J-PG*Y`#$t~MmU2!0DD)z44ekPsyvl}CM`5mXYCiA5;y@?a2Ox&8iY&qJ` z0SHLwWd48pM=Wfjol4m zAo%g3Dfu6d0?r;#cn|FnE@;v1kLPxKm)NWUg<_%6IG_juYTOrh87fX9cTdmkPFsKX9qL9FGw27DN2X?eZl(^mRf)*Pn|2R*)HtA}ov+ zem6~mchD4Uhk!y{YK1Ce?XOy?*mt50?D3!Lb3g_)Aj?&s)h5Ef~EZ^Qnt%_#K?+DY`fcdVZ=>4Z?&EwChPh zDtC%Gt^|dD60I-00P>$}l<9ka&W}lIqE<>4xN%5~;zw{MV!dK+KX{$yE zs5X32Z8%Z-tNJcVUrJ|)Jg}9u;Mr$s0rj6g!-8gF=4%C+kHZnIw%NJQt9MSoPxiy^ zAaYYQL_squoct@l4kP5jNT(z4w#s%djy%6z8wVcO0Y(ORwMEj=vC=Miq5iR|tn~5o zT^$=I%mtkOyWIC%jJhc^b~-*ze{-xH6wkM7YnH9DzKERM+(+q@jwGwpDNxFVMEuNL*yqLy}@^x4+&yom5hu`lK6IXY(=F|Gp7Y!8K9G_8A(6d zstxJ$DGMy=^T3$d?aSeblamA`gpfa9Ng!A_A%%rWJ952H+CHKkQp&;`E&ahU2G6p1 zso#M*5?GpGZZnh0&y?IEqb)&h&wPhdla<^r92@D+kQ;nH)6iVN8R^=hF5gpUn5IA@ z2PV*QuT<7nzmPs?`g4a6Lx{YkE?%9Mxl7)Ehfs`&9Eg!p0tKr{@bqz~K+A}sdOdaa+6`zUkqBvO9n%)o%%keERXa{r zWd%`pPzo|b=J&2u0{LZW=E1|Ni8YYO%1!q2*XQhdLExg25BWUhNl6I_FV7B^!wwr+ zpZ~d|A&a3vzUKE;Ha0dsbmK&_+!*o9%aU0Cz-jI0Sv|s-l0+nf%b2iI6^zklEkHaWghN znoHQmaa44T4RX2(QM!u*IK?0UEM0;f3EWXo!YkxTDr%lG_Qp15Kr9z)W=vS%&Txu*is|1>F&C-Xfc+ix#QB|G- z?H_Z^3ceX$&eduqC5 z#pS?vRA)S_{G_d<Gph!u|D@#dY2*JdY+H7PYy$dRgMGB36HlhwvX{#pKzETMS zV|=z9tB#!PXU}Ql*u(&j!}sJ$6w`+6kVkQFagT%o!EbQxV!I-ix6@D;j);mHL-h;< z7c;xyaqga#(&uZ<%&Yy#&P+Dc-bl)t4!6=)WMrk4o*8G?3dg5?a;?LHbC-FvA)!4Y z8MFUpsjjs zjRqn+pvj(KUixN*Sf2fSEVFF8T3CAH!DAiPzR8>sca{YwYZkd073H`i5u!E@u}FnK zUuqFD0YqSM$Y4%R&Jo^dVq4m?IC5%2;V%_GS!nzg_XN#eXyPEygg-<)VKSuFc5!+JKXhmeyst z`ZOoxi#N)fyX0xSI^RFFeL2Sfv$A$rftAjS!F2ZKS0*%Ps6*ZZQ4RLO6o8%0!Bqq! zYZPj(;QOj=9MbrK;2*v&kz#jnNXjTpf#95wk!rkQ<=R7#9%bLOI>lC?rK``6bEpa+ zsW(aIQbqK6MF&ng9?+Mv;rKGLSjG8L#>R2JfZRS`VGxG?xdZ3b6G0hK{m8(@C6UYX zR7T}Tb)jXZgdY2K;lpj!tFmjhXa+n`Re1t*o{U!uWoXo*(p7?`?@}H7=ZQZ8k`cxv z>PYphmUA^mv4=X~u?q6DHvKjsi!cUUK!0Ub9@&h?ytup z*GGW6cxwuBJ(cuOO6wVy;xFiVgxP>fY^bc#<*75Yywx8txKlv>aR1WldkmaUyP8y7 zUaNI|J z2rVk(&;8mUXR%QXw(=Sng$PCw>sUv&*bC?OQ@zY*g5a+_82^>Z`ue?LTCv>R=MnC| zoT2(CK|l2!;;Fv~I?@d(PhnXg{$j0hnl>*Z=Ub?{l&l~U%E;)LRLBT9DIh?J5jM*e zuB?p!wN}m)bDQ-dAzWPCeDOVZwT?T{J=i&)TCUJua&f6Yf);(~z|U$61h>!^6*;7` z-G*+J=mJs8oj@>M0`xN&c_e`t?G7`+Zt%x|7O|vZ6cl}du*i*R-v*=x{GG=`cq5`% z&>~Mf8HyDAorzAKJ2)tjujtSsku;2Cvvk8W>I7(u`6}MXs0?_6ECtH`>*-x?5AM&I z0;OVAmv@@(9;y3Kp`5k{J`BAetN?EBK9rC?zbI5*Mp?&$sw&Zs^&lQHO6EhEW zB&Q~ESU|_4I7Tb!A=D#uc+}0N)>ymJlHX0n(J}72NL@xnG7j#jt<2WOK@xN)ePlsC zG}Nxf2SwDNj)-Fdnko4D0vFO>jAUJ`u*rFG$lkorI8Z_s9RId7#r!EqR(@RBGKqqc z63`XY;eJ4olCrvxM;Mej?vla`BC7&!?VY*J4?!jo5dAZ`noKM@0`Q-b=8^t$x2#Mqm>l(#ByzuW$qw+zJG5 zc;y45_FoVAX>@gc8m;5k{8ga81;d~%yna8g8% z;(#o#bEPEZK<2=sfC_jR2jYW9&e5SEzsi*E!aryNij{^h5z&`ObSq|d7kULX^9*9K zN*5M6l+idGT);3Exb6hI2#_RL7^%3_b8?jL0SG&dX$FX7ryvqndgx=&2>2B9E>R@j zA29od0F06%iV*-ei<(KSf&7$Vt)t_aMFAYS9e~dT;#0I?I|nn5{GS^vLO={kDX?-p zR8$?hymFZ(fKT~Bl>)Vj8QFXQrDK#6taD>w)QXFXJ329H=U9LxI^Dq=l5rpc8dVM7(5kLi zPOC6ZOH)G`s5Ap^ay0TiQCsz(uZZehNY)ml79bT82VliC!($-Kh_Va$7%0DdjhR&h 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 literal 111824 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 $&blZvHHdhp|(U$!7gmduOC7f`) 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%0A7E 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
Evj7_xv+r{3n&&`uXuR>8vvr4;R%Zk59 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 W@L#6w-MIQtT{vfXw&2XQ$o~a}k!3aj diff --git a/docs/user/security/api-keys/images/create-api-key.png b/docs/user/security/api-keys/images/create-api-key.png new file mode 100644 index 0000000000000000000000000000000000000000..c763dcbfa53f8600a92caa42f50c11943b3ebad2 GIT binary patch literal 377920 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 literal 0 HcmV?d00001 diff --git a/docs/user/security/api-keys/index.asciidoc b/docs/user/security/api-keys/index.asciidoc index 3edd1f8f9c63d4..6b92ab3c6656a0 100644 --- a/docs/user/security/api-keys/index.asciidoc +++ b/docs/user/security/api-keys/index.asciidoc @@ -4,7 +4,7 @@ API keys enable you to create secondary credentials so that you can send -requests on behalf of the user. Secondary credentials have +requests on behalf of a user. Secondary credentials have the same or lower access rights. For example, if you extract data from an {es} cluster on a daily @@ -14,8 +14,7 @@ and then put the API credentials into a cron job. Or, you might create API keys to automate ingestion of new data from remote sources, without a live user interaction. -You can create API keys from the {kib} Console. To view and invalidate -API keys, open the main menu, then click *Stack Management > API Keys*. +To manage API keys, open the main menu, then click *Stack Management > API Keys*. [role="screenshot"] image:user/security/api-keys/images/api-keys.png["API Keys UI"] @@ -46,37 +45,15 @@ cluster privileges to use API keys in {kib}. To manage roles, open the main menu [float] [[create-api-key]] === Create an API key -You can {ref}/security-api-create-api-key.html[create an API key] from -the {kib} Console. This example shows how to create an API key -to authenticate to a <>. - -[source,js] -POST /_security/api_key -{ - "name": "kibana_api_key" -} - -This creates an API key with the -name `kibana_api_key`. API key -names must be globally unique. -An expiration date is optional and follows -{ref}/common-options.html#time-units[{es} time unit format]. -When an expiration is not provided, the API key does not expire. - -The response should look something like this: - -[source,js] -{ - "id" : "XFcbCnIBnbwqt2o79G4q", - "name" : "kibana_api_key", - "api_key" : "FD6P5UA4QCWlZZQhYF3YGw" -} - -Now, you can use the API key to request {kib} roles. You'll need to send a request with a -`Authorization` header with a value having the prefix `ApiKey` followed by the credentials, -where credentials is the base64 encoding of `id` and `api_key` joined by a colon. For example: - -[source,js] + +To create an API key, open the main menu, then click *Stack Management > API Keys > Create API key*. + +[role="screenshot"] +image:user/security/api-keys/images/create-api-key.png["Create API Key UI"] + +Once created, you can copy the API key (Base64 encoded) and use it to send requests to {es} on your behalf. For example: + +[source,bash] curl --location --request GET 'http://localhost:5601/api/security/role' \ --header 'Content-Type: application/json;charset=UTF-8' \ --header 'kbn-xsrf: true' \ @@ -84,20 +61,16 @@ curl --location --request GET 'http://localhost:5601/api/security/role' \ [float] [[view-api-keys]] -=== View and invalidate API keys -The *API Keys* feature in Kibana lists your API keys, including the name, date created, -and expiration date. If an API key expires, its status changes from `Active` to `Expired`. +=== View and delete API keys + +The *API Keys* feature in Kibana lists your API keys, including the name, date created, and status. If an API key expires, its status changes from `Active` to `Expired`. If you have `manage_security` or `manage_api_key` permissions, you can view the API keys of all users, and see which API key was created by which user in which realm. If you have only the `manage_own_api_key` permission, you see only a list of your own keys. -You can invalidate API keys individually or in bulk. -Invalidated keys are deleted in batch after seven days. - -[role="screenshot"] -image:user/security/api-keys/images/api-key-invalidate.png["API Keys invalidate"] +You can delete API keys individually or in bulk. You cannot modify an API key. If you need additional privileges, you must create a new key with the desired configuration and invalidate the old key. diff --git a/src/plugins/kibana_react/public/code_editor/__snapshots__/code_editor.test.tsx.snap b/src/plugins/kibana_react/public/code_editor/__snapshots__/code_editor.test.tsx.snap index 2800c6cd7c198a..a779ef540d72ec 100644 --- a/src/plugins/kibana_react/public/code_editor/__snapshots__/code_editor.test.tsx.snap +++ b/src/plugins/kibana_react/public/code_editor/__snapshots__/code_editor.test.tsx.snap @@ -9,7 +9,21 @@ exports[`is rendered 1`] = ` height={250} language="loglang" onChange={[Function]} - options={Object {}} + options={ + Object { + "minimap": Object { + "enabled": false, + }, + "renderLineHighlight": "none", + "scrollBeyondLastLine": false, + "scrollbar": Object { + "useShadows": false, + }, + "wordBasedSuggestions": false, + "wordWrap": "on", + "wrappingIndent": "indent", + } + } overrideServices={Object {}} theme="euiColors" value=" diff --git a/src/plugins/kibana_react/public/code_editor/code_editor.stories.tsx b/src/plugins/kibana_react/public/code_editor/code_editor.stories.tsx index a5fdfe773a2f8d..09c46bf7a327e0 100644 --- a/src/plugins/kibana_react/public/code_editor/code_editor.stories.tsx +++ b/src/plugins/kibana_react/public/code_editor/code_editor.stories.tsx @@ -78,6 +78,25 @@ storiesOf('CodeEditor', module) }, } ) + .add( + 'transparent background', + () => ( +

+ +
+ ), + { + info: { + text: 'Plaintext Monaco Editor', + }, + } + ) .add( 'custom log language', () => ( diff --git a/src/plugins/kibana_react/public/code_editor/code_editor.test.tsx b/src/plugins/kibana_react/public/code_editor/code_editor.test.tsx index 33f0f311d3a4a3..0f279e3bfea325 100644 --- a/src/plugins/kibana_react/public/code_editor/code_editor.test.tsx +++ b/src/plugins/kibana_react/public/code_editor/code_editor.test.tsx @@ -89,8 +89,8 @@ test('editor mount setup', () => { // Verify our mount callback will be called expect(editorWillMount.mock.calls.length).toBe(1); - // Verify our theme will be setup - expect((monaco.editor.defineTheme as jest.Mock).mock.calls.length).toBe(1); + // Verify that both, default and transparent theme will be setup + expect((monaco.editor.defineTheme as jest.Mock).mock.calls.length).toBe(2); // Verify our language features have been registered expect((monaco.languages.onLanguage as jest.Mock).mock.calls.length).toBe(1); diff --git a/src/plugins/kibana_react/public/code_editor/code_editor.tsx b/src/plugins/kibana_react/public/code_editor/code_editor.tsx index cb96f077b219b9..51344e2d28ab67 100644 --- a/src/plugins/kibana_react/public/code_editor/code_editor.tsx +++ b/src/plugins/kibana_react/public/code_editor/code_editor.tsx @@ -9,10 +9,14 @@ import React from 'react'; import ReactResizeDetector from 'react-resize-detector'; import MonacoEditor from 'react-monaco-editor'; - import { monaco } from '@kbn/monaco'; -import { LIGHT_THEME, DARK_THEME } from './editor_theme'; +import { + DARK_THEME, + LIGHT_THEME, + DARK_THEME_TRANSPARENT, + LIGHT_THEME_TRANSPARENT, +} from './editor_theme'; import './editor.scss'; @@ -86,6 +90,11 @@ export interface Props { * Should the editor use the dark theme */ useDarkTheme?: boolean; + + /** + * Should the editor use a transparent background + */ + transparentBackground?: boolean; } export class CodeEditor extends React.Component { @@ -132,8 +141,12 @@ export class CodeEditor extends React.Component { } }); - // Register the theme + // Register themes monaco.editor.defineTheme('euiColors', this.props.useDarkTheme ? DARK_THEME : LIGHT_THEME); + monaco.editor.defineTheme( + 'euiColorsTransparent', + this.props.useDarkTheme ? DARK_THEME_TRANSPARENT : LIGHT_THEME_TRANSPARENT + ); }; _editorDidMount = (editor: monaco.editor.IStandaloneCodeEditor, __monaco: unknown) => { @@ -152,20 +165,33 @@ export class CodeEditor extends React.Component { const { languageId, value, onChange, width, height, options } = this.props; return ( - + <> - + ); } diff --git a/src/plugins/kibana_react/public/code_editor/editor_theme.ts b/src/plugins/kibana_react/public/code_editor/editor_theme.ts index b5d4627a5d89a3..0f362a28ea622c 100644 --- a/src/plugins/kibana_react/public/code_editor/editor_theme.ts +++ b/src/plugins/kibana_react/public/code_editor/editor_theme.ts @@ -16,7 +16,8 @@ import lightTheme from '@elastic/eui/dist/eui_theme_light.json'; export function createTheme( euiTheme: typeof darkTheme | typeof lightTheme, - selectionBackgroundColor: string + selectionBackgroundColor: string, + backgroundColor?: string ): monaco.editor.IStandaloneThemeData { return { base: 'vs', @@ -87,7 +88,7 @@ export function createTheme( ], colors: { 'editor.foreground': euiTheme.euiColorDarkestShade, - 'editor.background': euiTheme.euiFormBackgroundColor, + 'editor.background': backgroundColor ?? euiTheme.euiFormBackgroundColor, 'editorLineNumber.foreground': euiTheme.euiColorDarkShade, 'editorLineNumber.activeForeground': euiTheme.euiColorDarkShade, 'editorIndentGuide.background': euiTheme.euiColorLightShade, @@ -105,5 +106,7 @@ export function createTheme( const DARK_THEME = createTheme(darkTheme, '#343551'); const LIGHT_THEME = createTheme(lightTheme, '#E3E4ED'); +const DARK_THEME_TRANSPARENT = createTheme(darkTheme, '#343551', '#00000000'); +const LIGHT_THEME_TRANSPARENT = createTheme(lightTheme, '#E3E4ED', '#00000000'); -export { DARK_THEME, LIGHT_THEME }; +export { DARK_THEME, LIGHT_THEME, DARK_THEME_TRANSPARENT, LIGHT_THEME_TRANSPARENT }; diff --git a/src/plugins/kibana_react/public/code_editor/index.tsx b/src/plugins/kibana_react/public/code_editor/index.tsx index 1607e2b2c11be0..635e84b1d8c202 100644 --- a/src/plugins/kibana_react/public/code_editor/index.tsx +++ b/src/plugins/kibana_react/public/code_editor/index.tsx @@ -7,7 +7,14 @@ */ import React from 'react'; -import { EuiDelayRender, EuiLoadingContent } from '@elastic/eui'; +import { + EuiDelayRender, + EuiErrorBoundary, + EuiLoadingContent, + EuiFormControlLayout, +} from '@elastic/eui'; +import darkTheme from '@elastic/eui/dist/eui_theme_dark.json'; +import lightTheme from '@elastic/eui/dist/eui_theme_light.json'; import { useUiSetting } from '../ui_settings'; import type { Props } from './code_editor'; @@ -19,11 +26,54 @@ const Fallback = () => ( ); +/** + * Renders a Monaco code editor with EUI color theme. + * + * @see CodeEditorField to render a code editor in the same style as other EUI form fields. + */ export const CodeEditor: React.FunctionComponent = (props) => { const darkMode = useUiSetting('theme:darkMode'); return ( - }> - - + + }> + + + + ); +}; + +/** + * Renders a Monaco code editor in the same style as other EUI form fields. + */ +export const CodeEditorField: React.FunctionComponent = (props) => { + const { width, height, options } = props; + const darkMode = useUiSetting('theme:darkMode'); + const theme = darkMode ? darkTheme : lightTheme; + const style = { + width, + height, + backgroundColor: options?.readOnly + ? theme.euiFormBackgroundReadOnlyColor + : theme.euiFormBackgroundColor, + }; + + return ( + + + ); }; diff --git a/x-pack/plugins/security/common/model/api_key.ts b/x-pack/plugins/security/common/model/api_key.ts index 08f8378d145cea..f2467468f8069b 100644 --- a/x-pack/plugins/security/common/model/api_key.ts +++ b/x-pack/plugins/security/common/model/api_key.ts @@ -5,6 +5,8 @@ * 2.0. */ +import type { Role } from './role'; + export interface ApiKey { id: string; name: string; @@ -19,3 +21,5 @@ export interface ApiKeyToInvalidate { id: string; name: string; } + +export type ApiKeyRoleDescriptors = Record; diff --git a/x-pack/plugins/security/common/model/index.ts b/x-pack/plugins/security/common/model/index.ts index bca8b69d03fca1..8eb341ef9bd371 100644 --- a/x-pack/plugins/security/common/model/index.ts +++ b/x-pack/plugins/security/common/model/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -export { ApiKey, ApiKeyToInvalidate } from './api_key'; +export { ApiKey, ApiKeyToInvalidate, ApiKeyRoleDescriptors } from './api_key'; export { User, EditUser, getUserDisplayName } from './user'; export { AuthenticatedUser, canUserChangePassword } from './authenticated_user'; export { AuthenticationProvider, shouldProviderUseLoginForm } from './authentication_provider'; diff --git a/x-pack/plugins/security/public/components/breadcrumb.tsx b/x-pack/plugins/security/public/components/breadcrumb.tsx index 4462e2bce6abc7..353f738501cbef 100644 --- a/x-pack/plugins/security/public/components/breadcrumb.tsx +++ b/x-pack/plugins/security/public/components/breadcrumb.tsx @@ -9,6 +9,8 @@ import type { EuiBreadcrumb } from '@elastic/eui'; import type { FunctionComponent } from 'react'; import React, { createContext, useContext, useEffect, useRef } from 'react'; +import type { ChromeStart } from 'src/core/public'; + import { useKibana } from '../../../../../src/plugins/kibana_react/public'; interface BreadcrumbsContext { @@ -81,8 +83,8 @@ export const BreadcrumbsProvider: FunctionComponent = if (onChange) { onChange(breadcrumbs); } else if (services.chrome) { - services.chrome.setBreadcrumbs(breadcrumbs); - services.chrome.docTitle.change(getDocTitle(breadcrumbs)); + const setBreadcrumbs = createBreadcrumbsChangeHandler(services.chrome); + setBreadcrumbs(breadcrumbs); } }; @@ -138,3 +140,17 @@ export function getDocTitle(breadcrumbs: BreadcrumbProps[], maxBreadcrumbs = 2) .reverse() .map(({ text }) => text); } + +export function createBreadcrumbsChangeHandler( + chrome: Pick, + setBreadcrumbs = chrome.setBreadcrumbs +) { + return (breadcrumbs: BreadcrumbProps[]) => { + setBreadcrumbs(breadcrumbs); + if (breadcrumbs.length === 0) { + chrome.docTitle.reset(); + } else { + chrome.docTitle.change(getDocTitle(breadcrumbs)); + } + }; +} diff --git a/x-pack/plugins/security/public/components/confirm_modal.tsx b/x-pack/plugins/security/public/components/confirm_modal.tsx deleted file mode 100644 index 80c2008642d049..00000000000000 --- a/x-pack/plugins/security/public/components/confirm_modal.tsx +++ /dev/null @@ -1,84 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { EuiButtonProps, EuiModalProps } from '@elastic/eui'; -import { - EuiButton, - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiModal, - EuiModalBody, - EuiModalFooter, - EuiModalHeader, - EuiModalHeaderTitle, -} from '@elastic/eui'; -import type { FunctionComponent } from 'react'; -import React from 'react'; - -import { FormattedMessage } from '@kbn/i18n/react'; - -export interface ConfirmModalProps extends Omit { - confirmButtonText: string; - confirmButtonColor?: EuiButtonProps['color']; - isLoading?: EuiButtonProps['isLoading']; - isDisabled?: EuiButtonProps['isDisabled']; - onCancel(): void; - onConfirm(): void; -} - -/** - * Component that renders a confirmation modal similar to `EuiConfirmModal`, except that - * it adds `isLoading` prop, which renders a loading spinner and disables action buttons. - */ -export const ConfirmModal: FunctionComponent = ({ - children, - confirmButtonColor: buttonColor, - confirmButtonText, - isLoading, - isDisabled, - onCancel, - onConfirm, - title, - ...rest -}) => ( - - - {title} - - {children} - - - - - - - - - - {confirmButtonText} - - - - - -); diff --git a/x-pack/plugins/security/public/components/token_field.tsx b/x-pack/plugins/security/public/components/token_field.tsx new file mode 100644 index 00000000000000..98eee9352937c0 --- /dev/null +++ b/x-pack/plugins/security/public/components/token_field.tsx @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EuiFieldTextProps } from '@elastic/eui'; +import { + EuiButtonEmpty, + EuiButtonIcon, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiCopy, + EuiFormControlLayout, + EuiHorizontalRule, + EuiPopover, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import type { FunctionComponent, ReactElement } from 'react'; +import React from 'react'; + +import { i18n } from '@kbn/i18n'; +import { euiThemeVars } from '@kbn/ui-shared-deps/theme'; + +export interface TokenFieldProps extends Omit { + value: string; +} + +export const TokenField: FunctionComponent = (props) => { + return ( + + {(copyText) => ( + + )} + + } + style={{ backgroundColor: 'transparent' }} + readOnly + > + event.currentTarget.select()} + readOnly + /> + + ); +}; + +export interface SelectableTokenFieldOption { + key: string; + value: string; + icon?: string; + label: string; + description?: string; +} + +export interface SelectableTokenFieldProps extends Omit { + options: SelectableTokenFieldOption[]; +} + +export const SelectableTokenField: FunctionComponent = (props) => { + const { options, ...rest } = props; + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); + const [selectedOption, setSelectedOption] = React.useState( + options[0] + ); + const selectedIndex = options.findIndex((c) => c.key === selectedOption.key); + const closePopover = () => setIsPopoverOpen(false); + + return ( + setIsPopoverOpen(!isPopoverOpen)} + > + {selectedOption.label} + + } + isOpen={isPopoverOpen} + panelPaddingSize="none" + closePopover={closePopover} + > + ((items, option, i) => { + items.push( + { + closePopover(); + setSelectedOption(option); + }} + > + {option.label} + + +

{option.description}

+
+
+ ); + if (i < options.length - 1) { + items.push(); + } + return items; + }, [])} + /> + + } + value={selectedOption.value} + /> + ); +}; diff --git a/x-pack/plugins/security/public/components/use_initial_focus.ts b/x-pack/plugins/security/public/components/use_initial_focus.ts new file mode 100644 index 00000000000000..d8dd57f81070f8 --- /dev/null +++ b/x-pack/plugins/security/public/components/use_initial_focus.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DependencyList } from 'react'; +import { useEffect, useRef } from 'react'; + +/** + * Creates a ref for an HTML element, which will be focussed on mount. + * + * @example + * ```typescript + * const firstInput = useInitialFocus(); + * + * + * ``` + * + * Pass in a dependency list to focus conditionally rendered components: + * + * @example + * ```typescript + * const firstInput = useInitialFocus([showField]); + * + * {showField ? : undefined} + * ``` + */ +export function useInitialFocus(deps: DependencyList = []) { + const inputRef = useRef(null); + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, deps); // eslint-disable-line react-hooks/exhaustive-deps + return inputRef; +} diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.mock.ts b/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.mock.ts index cfb20229d3f6be..1ba35a20a5e5f6 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.mock.ts +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.mock.ts @@ -10,5 +10,6 @@ export const apiKeysAPIClientMock = { checkPrivileges: jest.fn(), getApiKeys: jest.fn(), invalidateApiKeys: jest.fn(), + createApiKey: jest.fn(), }), }; diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.test.ts b/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.test.ts index 8c79ee5bb0be50..03c256942ea5d3 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.test.ts +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.test.ts @@ -84,4 +84,20 @@ describe('APIKeysAPIClient', () => { body: JSON.stringify({ apiKeys: mockAPIKeys, isAdmin: true }), }); }); + + it('createApiKey() queries correct endpoint', async () => { + const httpMock = httpServiceMock.createStartContract(); + + const mockResponse = Symbol('mockResponse'); + httpMock.post.mockResolvedValue(mockResponse); + + const apiClient = new APIKeysAPIClient(httpMock); + const mockAPIKeys = { name: 'name', expiration: '7d' }; + + await expect(apiClient.createApiKey(mockAPIKeys)).resolves.toBe(mockResponse); + expect(httpMock.post).toHaveBeenCalledTimes(1); + expect(httpMock.post).toHaveBeenCalledWith('/internal/security/api_key', { + body: JSON.stringify(mockAPIKeys), + }); + }); }); diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.ts b/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.ts index 318837f0913279..65540fd7ebfc15 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.ts +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.ts @@ -7,23 +7,36 @@ import type { HttpStart } from 'src/core/public'; -import type { ApiKey, ApiKeyToInvalidate } from '../../../common/model'; +import type { ApiKey, ApiKeyRoleDescriptors, ApiKeyToInvalidate } from '../../../common/model'; -interface CheckPrivilegesResponse { +export interface CheckPrivilegesResponse { areApiKeysEnabled: boolean; isAdmin: boolean; canManage: boolean; } -interface InvalidateApiKeysResponse { +export interface InvalidateApiKeysResponse { itemsInvalidated: ApiKeyToInvalidate[]; errors: any[]; } -interface GetApiKeysResponse { +export interface GetApiKeysResponse { apiKeys: ApiKey[]; } +export interface CreateApiKeyRequest { + name: string; + expiration?: string; + role_descriptors?: ApiKeyRoleDescriptors; +} + +export interface CreateApiKeyResponse { + id: string; + name: string; + expiration: number; + api_key: string; +} + const apiKeysUrl = '/internal/security/api_key'; export class APIKeysAPIClient { @@ -42,4 +55,10 @@ export class APIKeysAPIClient { body: JSON.stringify({ apiKeys, isAdmin }), }); } + + public async createApiKey(apiKey: CreateApiKeyRequest) { + return await this.http.post(apiKeysUrl, { + body: JSON.stringify(apiKey), + }); + } } diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/__snapshots__/api_keys_grid_page.test.tsx.snap b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/__snapshots__/api_keys_grid_page.test.tsx.snap deleted file mode 100644 index a743c4e610da3c..00000000000000 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/__snapshots__/api_keys_grid_page.test.tsx.snap +++ /dev/null @@ -1,243 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`APIKeysGridPage renders a callout when API keys are not enabled 1`] = ` - - } -> -
- -`; - -exports[`APIKeysGridPage renders permission denied if user does not have required permissions 1`] = ` - - -
- - -
- - -

- } - iconType="securityApp" - title={ -

- -

- } - > -
- - - - -
- - - - -

- - You need permission to manage API keys - -

-
- -
- - -
-

- - Contact your system administrator. - -

-
-
- - -
- -
- - -
- - -`; diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_empty_prompt.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_empty_prompt.tsx new file mode 100644 index 00000000000000..eaded9a5c83ee1 --- /dev/null +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_empty_prompt.tsx @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiAccordion, EuiEmptyPrompt, EuiErrorBoundary, EuiSpacer, EuiText } from '@elastic/eui'; +import type { FunctionComponent } from 'react'; +import React from 'react'; + +import { FormattedMessage } from '@kbn/i18n/react'; + +import { DocLink } from '../../../components/doc_link'; +import { useHtmlId } from '../../../components/use_html_id'; + +export interface ApiKeysEmptyPromptProps { + error?: Error; +} + +export const ApiKeysEmptyPrompt: FunctionComponent = ({ + error, + children, +}) => { + const accordionId = useHtmlId('apiKeysEmptyPrompt', 'accordion'); + + if (error) { + if (doesErrorIndicateAPIKeysAreDisabled(error)) { + return ( + +

+ +

+

+ + + +

+ + } + /> + ); + } + + if (doesErrorIndicateUserHasNoPermissionsToManageAPIKeys(error)) { + return ( + + +

+ } + /> + ); + } + + const ThrowError = () => { + throw error; + }; + + return ( + + +

+ } + actions={ + <> + {children} + + + + } + buttonProps={{ + style: { display: 'flex', justifyContent: 'center' }, + }} + arrowDisplay="right" + paddingSize="m" + > + + + + + + + + } + /> + ); + } + + return ( + + + + } + body={ +

+ +

+ } + actions={children} + /> + ); +}; + +function doesErrorIndicateAPIKeysAreDisabled(error: Record) { + const message = error.body?.message || ''; + return message.indexOf('disabled.feature="api_keys"') !== -1; +} + +function doesErrorIndicateUserHasNoPermissionsToManageAPIKeys(error: Record) { + return error.body?.statusCode === 403; +} diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx index ff9fbad5c05b52..ba879e99f15981 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx @@ -5,182 +5,292 @@ * 2.0. */ -import { EuiCallOut } from '@elastic/eui'; -import type { ReactWrapper } from 'enzyme'; +import { + fireEvent, + render, + waitFor, + waitForElementToBeRemoved, + within, +} from '@testing-library/react'; +import { createMemoryHistory } from 'history'; import React from 'react'; -import { mountWithIntl } from '@kbn/test/jest'; -import type { PublicMethodsOf } from '@kbn/utility-types'; -import { coreMock } from 'src/core/public/mocks'; -import { KibanaContextProvider } from 'src/plugins/kibana_react/public'; - -import type { APIKeysAPIClient } from '../api_keys_api_client'; +import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { mockAuthenticatedUser } from '../../../../common/model/authenticated_user.mock'; +import { securityMock } from '../../../mocks'; +import { Providers } from '../api_keys_management_app'; import { apiKeysAPIClientMock } from '../index.mock'; import { APIKeysGridPage } from './api_keys_grid_page'; -import { NotEnabled } from './not_enabled'; -import { PermissionDenied } from './permission_denied'; - -const mock500 = () => ({ body: { error: 'Internal Server Error', message: '', statusCode: 500 } }); - -const waitForRender = async ( - wrapper: ReactWrapper, - condition: (wrapper: ReactWrapper) => boolean -) => { - return new Promise((resolve, reject) => { - const interval = setInterval(async () => { - await Promise.resolve(); - wrapper.update(); - if (condition(wrapper)) { - resolve(); - } - }, 10); - - setTimeout(() => { - clearInterval(interval); - reject(new Error('waitForRender timeout after 2000ms')); - }, 2000); - }); -}; -describe('APIKeysGridPage', () => { - let apiClientMock: jest.Mocked>; - beforeEach(() => { - apiClientMock = apiKeysAPIClientMock.create(); - apiClientMock.checkPrivileges.mockResolvedValue({ - isAdmin: true, - areApiKeysEnabled: true, - canManage: true, - }); - apiClientMock.getApiKeys.mockResolvedValue({ - apiKeys: [ - { - creation: 1571322182082, - expiration: 1571408582082, - id: '0QQZ2m0BO2XZwgJFuWTT', - invalidated: false, - name: 'my-api-key', - realm: 'reserved', - username: 'elastic', - }, - ], - }); - }); +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ + htmlIdGenerator: () => () => `id-${Math.random()}`, +})); + +jest.setTimeout(15000); + +const coreStart = coreMock.createStart(); + +const apiClientMock = apiKeysAPIClientMock.create(); +apiClientMock.checkPrivileges.mockResolvedValue({ + areApiKeysEnabled: true, + canManage: true, + isAdmin: true, +}); +apiClientMock.getApiKeys.mockResolvedValue({ + apiKeys: [ + { + creation: 1571322182082, + expiration: 1571408582082, + id: '0QQZ2m0BO2XZwgJFuWTT', + invalidated: false, + name: 'first-api-key', + realm: 'reserved', + username: 'elastic', + }, + { + creation: 1571322182082, + expiration: 1571408582082, + id: 'BO2XZwgJFuWTT0QQZ2m0', + invalidated: false, + name: 'second-api-key', + realm: 'reserved', + username: 'elastic', + }, + ], +}); - const coreStart = coreMock.createStart(); - const renderView = () => { - return mountWithIntl( - - - +const authc = securityMock.createSetup().authc; +authc.getCurrentUser.mockResolvedValue( + mockAuthenticatedUser({ + username: 'jdoe', + full_name: '', + email: '', + enabled: true, + roles: ['superuser'], + }) +); + +describe('APIKeysGridPage', () => { + it('loads and displays API keys', async () => { + const history = createMemoryHistory({ initialEntries: ['/'] }); + + const { getByText } = render( + + + ); - }; - it('renders a loading state when fetching API keys', async () => { - expect(renderView().find('[data-test-subj="apiKeysSectionLoading"]')).toHaveLength(1); + await waitForElementToBeRemoved(() => getByText(/Loading API keys/)); + getByText(/first-api-key/); + getByText(/second-api-key/); }); - it('renders a callout when API keys are not enabled', async () => { - apiClientMock.checkPrivileges.mockResolvedValue({ - isAdmin: true, - canManage: true, + it('displays callout when API keys are disabled', async () => { + const history = createMemoryHistory({ initialEntries: ['/'] }); + apiClientMock.checkPrivileges.mockResolvedValueOnce({ areApiKeysEnabled: false, + canManage: true, + isAdmin: true, }); - const wrapper = renderView(); - await waitForRender(wrapper, (updatedWrapper) => { - return updatedWrapper.find(NotEnabled).length > 0; - }); + const { getByText } = render( + + + + ); - expect(wrapper.find(NotEnabled).find(EuiCallOut)).toMatchSnapshot(); + await waitForElementToBeRemoved(() => getByText(/Loading API keys/)); + getByText(/API keys not enabled/); }); - it('renders permission denied if user does not have required permissions', async () => { - apiClientMock.checkPrivileges.mockResolvedValue({ + it('displays error when user does not have required permissions', async () => { + const history = createMemoryHistory({ initialEntries: ['/'] }); + apiClientMock.checkPrivileges.mockResolvedValueOnce({ + areApiKeysEnabled: true, canManage: false, isAdmin: false, - areApiKeysEnabled: true, }); - const wrapper = renderView(); - await waitForRender(wrapper, (updatedWrapper) => { - return updatedWrapper.find(PermissionDenied).length > 0; - }); + const { getByText } = render( + + + + ); - expect(wrapper.find(PermissionDenied)).toMatchSnapshot(); + await waitForElementToBeRemoved(() => getByText(/Loading API keys/)); + getByText(/You need permission to manage API keys/); }); - it('renders error callout if error fetching API keys', async () => { - apiClientMock.getApiKeys.mockRejectedValue(mock500()); - - const wrapper = renderView(); - await waitForRender(wrapper, (updatedWrapper) => { - return updatedWrapper.find(EuiCallOut).length > 0; + it('displays error when fetching API keys fails', async () => { + apiClientMock.getApiKeys.mockRejectedValueOnce({ + body: { error: 'Internal Server Error', message: '', statusCode: 500 }, }); + const history = createMemoryHistory({ initialEntries: ['/'] }); + + const { getByText } = render( + + + + ); - expect(wrapper.find('EuiCallOut[data-test-subj="apiKeysError"]')).toHaveLength(1); + await waitForElementToBeRemoved(() => getByText(/Loading API keys/)); + getByText(/Could not load API keys/); }); - describe('Admin view', () => { - let wrapper: ReactWrapper; - beforeEach(() => { - wrapper = renderView(); - }); + it('creates API key when submitting form, redirects back and displays base64', async () => { + const history = createMemoryHistory({ initialEntries: ['/create'] }); + coreStart.http.get.mockResolvedValue([{ name: 'superuser' }]); + coreStart.http.post.mockResolvedValue({ id: '1D', api_key: 'AP1_K3Y' }); + + const { findByRole, findByDisplayValue } = render( + + + + ); + expect(coreStart.http.get).toHaveBeenCalledWith('/api/security/role'); - it('renders a callout indicating the user is an administrator', async () => { - const calloutEl = 'EuiCallOut[data-test-subj="apiKeyAdminDescriptionCallOut"]'; + const dialog = await findByRole('dialog'); - await waitForRender(wrapper, (updatedWrapper) => { - return updatedWrapper.find(calloutEl).length > 0; - }); + fireEvent.click(await findByRole('button', { name: 'Create API key' })); + + const alert = await findByRole('alert'); + within(alert).getByText(/Enter a name/i); - expect(wrapper.find(calloutEl).text()).toEqual('You are an API Key administrator.'); + fireEvent.change(await within(dialog).findByLabelText('Name'), { + target: { value: 'Test' }, }); - it('renders the correct description text', async () => { - const descriptionEl = 'EuiText[data-test-subj="apiKeysDescriptionText"]'; + fireEvent.click(await findByRole('button', { name: 'Create API key' })); - await waitForRender(wrapper, (updatedWrapper) => { - return updatedWrapper.find(descriptionEl).length > 0; + await waitFor(() => { + expect(coreStart.http.post).toHaveBeenLastCalledWith('/internal/security/api_key', { + body: JSON.stringify({ name: 'Test' }), }); - - expect(wrapper.find(descriptionEl).text()).toEqual( - 'View and invalidate API keys. An API key sends requests on behalf of a user.' - ); + expect(history.location.pathname).toBe('/'); }); + + await findByDisplayValue(btoa('1D:AP1_K3Y')); }); - describe('Non-admin view', () => { - let wrapper: ReactWrapper; - beforeEach(() => { - apiClientMock.checkPrivileges.mockResolvedValue({ - isAdmin: false, - canManage: true, - areApiKeysEnabled: true, - }); + it('creates API key with optional expiration, redirects back and displays base64', async () => { + const history = createMemoryHistory({ initialEntries: ['/create'] }); + coreStart.http.get.mockResolvedValue([{ name: 'superuser' }]); + coreStart.http.post.mockResolvedValue({ id: '1D', api_key: 'AP1_K3Y' }); + + const { findByRole, findByDisplayValue } = render( + + + + ); + expect(coreStart.http.get).toHaveBeenCalledWith('/api/security/role'); - wrapper = renderView(); + const dialog = await findByRole('dialog'); + + fireEvent.change(await within(dialog).findByLabelText('Name'), { + target: { value: 'Test' }, }); - it('does NOT render a callout indicating the user is an administrator', async () => { - const descriptionEl = 'EuiText[data-test-subj="apiKeysDescriptionText"]'; - const calloutEl = 'EuiCallOut[data-test-subj="apiKeyAdminDescriptionCallOut"]'; + fireEvent.click(await within(dialog).findByLabelText('Expire after time')); - await waitForRender(wrapper, (updatedWrapper) => { - return updatedWrapper.find(descriptionEl).length > 0; - }); + fireEvent.click(await findByRole('button', { name: 'Create API key' })); - expect(wrapper.find(calloutEl).length).toEqual(0); + const alert = await findByRole('alert'); + within(alert).getByText(/Enter a valid duration or disable this option\./i); + + fireEvent.change(await within(dialog).findByLabelText('Lifetime (days)'), { + target: { value: '12' }, }); - it('renders the correct description text', async () => { - const descriptionEl = 'EuiText[data-test-subj="apiKeysDescriptionText"]'; + fireEvent.click(await findByRole('button', { name: 'Create API key' })); - await waitForRender(wrapper, (updatedWrapper) => { - return updatedWrapper.find(descriptionEl).length > 0; + await waitFor(() => { + expect(coreStart.http.post).toHaveBeenLastCalledWith('/internal/security/api_key', { + body: JSON.stringify({ name: 'Test', expiration: '12d' }), }); + expect(history.location.pathname).toBe('/'); + }); + + await findByDisplayValue(btoa('1D:AP1_K3Y')); + }); + + it('deletes api key using cta button', async () => { + const history = createMemoryHistory({ initialEntries: ['/'] }); + + const { findByRole, findAllByLabelText } = render( + + + + ); + + const [deleteButton] = await findAllByLabelText(/Delete/i); + fireEvent.click(deleteButton); + + const dialog = await findByRole('dialog'); + fireEvent.click(await within(dialog).findByRole('button', { name: 'Delete API key' })); + + await waitFor(() => { + expect(apiClientMock.invalidateApiKeys).toHaveBeenLastCalledWith( + [{ id: '0QQZ2m0BO2XZwgJFuWTT', name: 'first-api-key' }], + true + ); + }); + }); + + it('deletes multiple api keys using bulk select', async () => { + const history = createMemoryHistory({ initialEntries: ['/'] }); + + const { findByRole, findAllByRole } = render( + + + + ); + + const deleteCheckboxes = await findAllByRole('checkbox', { name: 'Select this row' }); + deleteCheckboxes.forEach((checkbox) => fireEvent.click(checkbox)); + fireEvent.click(await findByRole('button', { name: 'Delete API keys' })); + + const dialog = await findByRole('dialog'); + fireEvent.click(await within(dialog).findByRole('button', { name: 'Delete API keys' })); - expect(wrapper.find(descriptionEl).text()).toEqual( - 'View and invalidate your API keys. An API key sends requests on your behalf.' + await waitFor(() => { + expect(apiClientMock.invalidateApiKeys).toHaveBeenLastCalledWith( + [ + { id: '0QQZ2m0BO2XZwgJFuWTT', name: 'first-api-key' }, + { id: 'BO2XZwgJFuWTT0QQZ2m0', name: 'second-api-key' }, + ], + true ); }); }); diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.tsx index 62ca51be2ede8d..442c1d910f8142 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.tsx @@ -9,10 +9,11 @@ import type { EuiBasicTableColumn, EuiInMemoryTableProps } from '@elastic/eui'; import { EuiBadge, EuiButton, - EuiButtonIcon, EuiCallOut, EuiFlexGroup, EuiFlexItem, + EuiHealth, + EuiIcon, EuiInMemoryTable, EuiPageContent, EuiPageContentBody, @@ -23,8 +24,10 @@ import { EuiTitle, EuiToolTip, } from '@elastic/eui'; +import type { History } from 'history'; import moment from 'moment-timezone'; import React, { Component } from 'react'; +import { Route } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -32,14 +35,20 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import type { NotificationsStart } from 'src/core/public'; import { SectionLoading } from '../../../../../../../src/plugins/es_ui_shared/public'; +import { reactRouterNavigate } from '../../../../../../../src/plugins/kibana_react/public'; import type { ApiKey, ApiKeyToInvalidate } from '../../../../common/model'; -import type { APIKeysAPIClient } from '../api_keys_api_client'; -import { EmptyPrompt } from './empty_prompt'; +import { Breadcrumb } from '../../../components/breadcrumb'; +import { SelectableTokenField } from '../../../components/token_field'; +import type { APIKeysAPIClient, CreateApiKeyResponse } from '../api_keys_api_client'; +import { ApiKeysEmptyPrompt } from './api_keys_empty_prompt'; +import { CreateApiKeyFlyout } from './create_api_key_flyout'; +import type { InvalidateApiKeys } from './invalidate_provider'; import { InvalidateProvider } from './invalidate_provider'; import { NotEnabled } from './not_enabled'; import { PermissionDenied } from './permission_denied'; interface Props { + history: History; notifications: NotificationsStart; apiKeysAPIClient: PublicMethodsOf; } @@ -50,9 +59,10 @@ interface State { isAdmin: boolean; canManage: boolean; areApiKeysEnabled: boolean; - apiKeys: ApiKey[]; + apiKeys?: ApiKey[]; selectedItems: ApiKey[]; error: any; + createdApiKey?: CreateApiKeyResponse; } const DATE_FORMAT = 'MMMM Do YYYY HH:mm:ss'; @@ -66,7 +76,7 @@ export class APIKeysGridPage extends Component { isAdmin: false, canManage: false, areApiKeysEnabled: false, - apiKeys: [], + apiKeys: undefined, selectedItems: [], error: undefined, }; @@ -77,6 +87,31 @@ export class APIKeysGridPage extends Component { } public render() { + return ( +
+ + + { + this.props.history.push({ pathname: '/' }); + this.reloadApiKeys(); + this.setState({ createdApiKey: apiKey }); + }} + onCancel={() => this.props.history.push({ pathname: '/' })} + /> + + + {this.renderContent()} +
+ ); + } + + public renderContent() { const { isLoadingApp, isLoadingTable, @@ -87,104 +122,191 @@ export class APIKeysGridPage extends Component { apiKeys, } = this.state; - if (isLoadingApp) { - return ( - - - - - - ); - } - - if (!canManage) { - return ; - } - - if (error) { - const { - body: { error: errorTitle, message, statusCode }, - } = error; - - return ( - - + - } - color="danger" - iconType="alert" - data-test-subj="apiKeysError" - > - {statusCode}: {errorTitle} - {message} - - - ); - } + + + ); + } - if (!areApiKeysEnabled) { - return ( - - - - ); + if (!canManage) { + return ; + } + + if (error) { + return ( + + + + + + + + ); + } + + if (!areApiKeysEnabled) { + return ( + + + + ); + } } if (!isLoadingTable && apiKeys && apiKeys.length === 0) { return ( - + + + + + ); } - const description = ( - -

- {isAdmin ? ( - - ) : ( - - )} -

-
- ); + const concatenated = `${this.state.createdApiKey?.id}:${this.state.createdApiKey?.api_key}`; return ( -

+

-

+
- {description} + +

+ {isAdmin ? ( + + ) : ( + + )} +

+
+
+ + + +
+ {this.state.createdApiKey && !this.state.isLoadingTable && ( + <> + +

+ +

+ +
+ + + )} + {this.renderTable()}
); } private renderTable = () => { - const { apiKeys, selectedItems, isLoadingTable, isAdmin } = this.state; + const { apiKeys, selectedItems, isLoadingTable, isAdmin, error } = this.state; const message = isLoadingTable ? ( { const sorting = { sort: { - field: 'expiration', - direction: 'asc', + field: 'creation', + direction: 'desc', }, } as const; @@ -234,7 +356,7 @@ export class APIKeysGridPage extends Component { > { }} ) : undefined, - toolsRight: ( - this.reloadApiKeys()} - data-test-subj="reloadButton" - > - - - ), box: { incremental: true, }, @@ -270,14 +379,23 @@ export class APIKeysGridPage extends Component { }), multiSelect: false, options: Object.keys( - apiKeys.reduce((apiKeysMap: any, apiKey) => { + apiKeys?.reduce((apiKeysMap: any, apiKey) => { apiKeysMap[apiKey.username] = true; return apiKeysMap; - }, {}) + }, {}) ?? {} ).map((username) => { return { value: username, - view: username, + view: ( + + + + + + {username} + + + ), }; }), }, @@ -289,10 +407,10 @@ export class APIKeysGridPage extends Component { }), multiSelect: false, options: Object.keys( - apiKeys.reduce((apiKeysMap: any, apiKey) => { + apiKeys?.reduce((apiKeysMap: any, apiKey) => { apiKeysMap[apiKey.realm] = true; return apiKeysMap; - }, {}) + }, {}) ?? {} ).map((realm) => { return { value: realm, @@ -306,52 +424,58 @@ export class APIKeysGridPage extends Component { return ( <> - {isAdmin ? ( + {!isAdmin ? ( <> } - color="success" + color="primary" iconType="user" - size="s" - data-test-subj="apiKeyAdminDescriptionCallOut" /> - - + ) : undefined} - { - { - return { - 'data-test-subj': 'apiKeyRow', - }; - }} - /> - } + + {(invalidateApiKeyPrompt) => ( + + )} + ); }; - private getColumnConfig = () => { - const { isAdmin } = this.state; + private getColumnConfig = (invalidateApiKeyPrompt: InvalidateApiKeys) => { + const { isAdmin, createdApiKey } = this.state; + + let config: Array> = []; - let config: Array> = [ + config = config.concat([ { field: 'name', name: i18n.translate('xpack.security.management.apiKeys.table.nameColumnName', { @@ -359,7 +483,7 @@ export class APIKeysGridPage extends Component { }), sortable: true, }, - ]; + ]); if (isAdmin) { config = config.concat([ @@ -369,6 +493,16 @@ export class APIKeysGridPage extends Component { defaultMessage: 'User', }), sortable: true, + render: (username: string) => ( + + + + + + {username} + + + ), }, { field: 'realm', @@ -387,91 +521,83 @@ export class APIKeysGridPage extends Component { defaultMessage: 'Created', }), sortable: true, - render: (creationDateMs: number) => moment(creationDateMs).format(DATE_FORMAT), - }, - { - field: 'expiration', - name: i18n.translate('xpack.security.management.apiKeys.table.expirationDateColumnName', { - defaultMessage: 'Expires', - }), - sortable: true, - render: (expirationDateMs: number) => { - if (expirationDateMs === undefined) { - return ( - - {i18n.translate( - 'xpack.security.management.apiKeys.table.expirationDateNeverMessage', - { - defaultMessage: 'Never', - } - )} - - ); - } - - return moment(expirationDateMs).format(DATE_FORMAT); + mobileOptions: { + show: false, }, + render: (creation: string, item: ApiKey) => ( + + {item.id === createdApiKey?.id ? ( + + + + ) : ( + {moment(creation).fromNow()} + )} + + ), }, { name: i18n.translate('xpack.security.management.apiKeys.table.statusColumnName', { defaultMessage: 'Status', }), render: ({ expiration }: any) => { - const now = Date.now(); + if (!expiration) { + return ( + + + + ); + } - if (now > expiration) { - return Expired; + if (Date.now() > expiration) { + return ( + + + + ); } - return Active; + return ( + + + + + + ); }, }, { - name: i18n.translate('xpack.security.management.apiKeys.table.actionsColumnName', { - defaultMessage: 'Actions', - }), actions: [ { - render: ({ name, id }: any) => { - return ( - - - - {(invalidateApiKeyPrompt) => { - return ( - - - invalidateApiKeyPrompt([{ id, name }], this.onApiKeysInvalidated) - } - /> - - ); - }} - - - - ); - }, + name: i18n.translate('xpack.security.management.apiKeys.table.deleteAction', { + defaultMessage: 'Delete', + }), + description: i18n.translate( + 'xpack.security.management.apiKeys.table.deleteDescription', + { + defaultMessage: 'Delete this API key', + } + ), + icon: 'trash', + type: 'icon', + color: 'danger', + onClick: (item) => + invalidateApiKeyPrompt([{ id: item.id, name: item.name }], this.onApiKeysInvalidated), }, ], }, @@ -498,7 +624,7 @@ export class APIKeysGridPage extends Component { if (!canManage || !areApiKeysEnabled) { this.setState({ isLoadingApp: false }); } else { - this.initiallyLoadApiKeys(); + this.loadApiKeys(); } } catch (e) { this.props.notifications.toasts.addDanger( @@ -510,13 +636,13 @@ export class APIKeysGridPage extends Component { } } - private initiallyLoadApiKeys = () => { - this.setState({ isLoadingApp: true, isLoadingTable: false }); - this.loadApiKeys(); - }; - private reloadApiKeys = () => { - this.setState({ apiKeys: [], isLoadingApp: false, isLoadingTable: true }); + this.setState({ + isLoadingApp: false, + isLoadingTable: true, + createdApiKey: undefined, + error: undefined, + }); this.loadApiKeys(); }; diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/create_api_key_flyout.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/create_api_key_flyout.tsx new file mode 100644 index 00000000000000..27385e4b29b009 --- /dev/null +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/create_api_key_flyout.tsx @@ -0,0 +1,378 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiCallOut, + EuiFieldNumber, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormFieldset, + EuiFormRow, + EuiIcon, + EuiLoadingContent, + EuiSpacer, + EuiSwitch, + EuiText, +} from '@elastic/eui'; +import type { FunctionComponent } from 'react'; +import React, { useEffect } from 'react'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { CodeEditorField, useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import type { ApiKeyRoleDescriptors } from '../../../../common/model'; +import { DocLink } from '../../../components/doc_link'; +import type { FormFlyoutProps } from '../../../components/form_flyout'; +import { FormFlyout } from '../../../components/form_flyout'; +import { useCurrentUser } from '../../../components/use_current_user'; +import { useForm } from '../../../components/use_form'; +import type { ValidationErrors } from '../../../components/use_form'; +import { useInitialFocus } from '../../../components/use_initial_focus'; +import { RolesAPIClient } from '../../roles/roles_api_client'; +import { APIKeysAPIClient } from '../api_keys_api_client'; +import type { CreateApiKeyRequest, CreateApiKeyResponse } from '../api_keys_api_client'; + +export interface ApiKeyFormValues { + name: string; + expiration: string; + customExpiration: boolean; + customPrivileges: boolean; + role_descriptors: string; +} + +export interface CreateApiKeyFlyoutProps { + defaultValues?: ApiKeyFormValues; + onSuccess?: (apiKey: CreateApiKeyResponse) => void; + onCancel: FormFlyoutProps['onCancel']; +} + +const defaultDefaultValues: ApiKeyFormValues = { + name: '', + expiration: '', + customExpiration: false, + customPrivileges: false, + role_descriptors: JSON.stringify( + { + 'role-a': { + cluster: ['all'], + indices: [ + { + names: ['index-a*'], + privileges: ['read'], + }, + ], + }, + 'role-b': { + cluster: ['all'], + indices: [ + { + names: ['index-b*'], + privileges: ['all'], + }, + ], + }, + }, + null, + 2 + ), +}; + +export const CreateApiKeyFlyout: FunctionComponent = ({ + onSuccess, + onCancel, + defaultValues = defaultDefaultValues, +}) => { + const { services } = useKibana(); + const { value: currentUser, loading: isLoadingCurrentUser } = useCurrentUser(); + const [{ value: roles, loading: isLoadingRoles }, getRoles] = useAsyncFn( + () => new RolesAPIClient(services.http!).getRoles(), + [services.http] + ); + const [form, eventHandlers] = useForm({ + onSubmit: async (values) => { + try { + const apiKey = await new APIKeysAPIClient(services.http!).createApiKey(mapValues(values)); + onSuccess?.(apiKey); + } catch (error) { + throw error; + } + }, + validate, + defaultValues, + }); + const isLoading = isLoadingCurrentUser || isLoadingRoles; + + useEffect(() => { + getRoles(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (currentUser && roles) { + const userPermissions = currentUser.roles.reduce( + (accumulator, roleName) => { + const role = roles.find((r) => r.name === roleName); + if (role) { + accumulator[role.name] = role.elasticsearch; + } + return accumulator; + }, + {} + ); + if (!form.touched.role_descriptors) { + form.setValue('role_descriptors', JSON.stringify(userPermissions, null, 2)); + } + } + }, [currentUser, roles]); // eslint-disable-line react-hooks/exhaustive-deps + + const firstFieldRef = useInitialFocus([isLoading]); + + return ( + + {form.submitError && ( + <> + + {(form.submitError as any).body?.message || form.submitError.message} + + + + )} + + {isLoading ? ( + + ) : ( + + + + + + + + + + {currentUser?.username} + + + + + + + + + + + + + form.setValue('customPrivileges', e.target.checked)} + /> + {form.values.customPrivileges && ( + <> + + + + + } + error={form.errors.role_descriptors} + isInvalid={form.touched.role_descriptors && !!form.errors.role_descriptors} + > + form.setValue('role_descriptors', value)} + languageId="xjson" + height={200} + /> + + + + )} + + + + + form.setValue('customExpiration', e.target.checked)} + /> + {form.values.customExpiration && ( + <> + + + + + + + )} + + + {/* Hidden submit button is required for enter key to trigger form submission */} + + + )} + + ); +}; + +export function validate(values: ApiKeyFormValues) { + const errors: ValidationErrors = {}; + + if (!values.name) { + errors.name = i18n.translate('xpack.security.management.apiKeys.createApiKey.nameRequired', { + defaultMessage: 'Enter a name.', + }); + } + + if (values.customExpiration) { + const parsedExpiration = parseFloat(values.expiration); + if (isNaN(parsedExpiration) || parsedExpiration <= 0) { + errors.expiration = i18n.translate( + 'xpack.security.management.apiKeys.createApiKey.expirationRequired', + { + defaultMessage: 'Enter a valid duration or disable this option.', + } + ); + } + } + + if (values.customPrivileges) { + if (!values.role_descriptors) { + errors.role_descriptors = i18n.translate( + 'xpack.security.management.apiKeys.createApiKey.roleDescriptorsRequired', + { + defaultMessage: 'Enter role descriptors or disable this option.', + } + ); + } else { + try { + JSON.parse(values.role_descriptors); + } catch (e) { + errors.role_descriptors = i18n.translate( + 'xpack.security.management.apiKeys.createApiKey.invalidJsonError', + { + defaultMessage: 'Enter valid JSON.', + } + ); + } + } + } + + return errors; +} + +export function mapValues(values: ApiKeyFormValues): CreateApiKeyRequest { + return { + name: values.name, + expiration: values.customExpiration && values.expiration ? `${values.expiration}d` : undefined, + role_descriptors: + values.customPrivileges && values.role_descriptors + ? JSON.parse(values.role_descriptors) + : undefined, + }; +} diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/empty_prompt/empty_prompt.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/empty_prompt/empty_prompt.tsx deleted file mode 100644 index 0987f43a3d14d7..00000000000000 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/empty_prompt/empty_prompt.tsx +++ /dev/null @@ -1,76 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiButton, EuiEmptyPrompt, EuiLink } from '@elastic/eui'; -import React, { Fragment } from 'react'; - -import { FormattedMessage } from '@kbn/i18n/react'; - -import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; - -interface Props { - isAdmin: boolean; -} - -export const EmptyPrompt: React.FunctionComponent = ({ isAdmin }) => { - const { services } = useKibana(); - const application = services.application!; - const docLinks = services.docLinks!; - return ( - - {isAdmin ? ( - - ) : ( - - )} - - } - body={ - -

- - - - ), - }} - /> -

-
- } - actions={ - application.navigateToApp('dev_tools')} - data-test-subj="goToConsoleButton" - > - - - } - data-test-subj="emptyPrompt" - /> - ); -}; diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/empty_prompt/index.ts b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/empty_prompt/index.ts deleted file mode 100644 index c68b2c170df5b1..00000000000000 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/empty_prompt/index.ts +++ /dev/null @@ -1,8 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export { EmptyPrompt } from './empty_prompt'; diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/invalidate_provider/index.ts b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/invalidate_provider/index.ts index 4eab1c881c2217..dc99861ce0a8db 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/invalidate_provider/index.ts +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/invalidate_provider/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { InvalidateProvider } from './invalidate_provider'; +export { InvalidateProvider, InvalidateApiKeys } from './invalidate_provider'; diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/invalidate_provider/invalidate_provider.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/invalidate_provider/invalidate_provider.tsx index a68534db4fd85a..26d1e1f72d31f2 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/invalidate_provider/invalidate_provider.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/invalidate_provider/invalidate_provider.tsx @@ -41,7 +41,7 @@ export const InvalidateProvider: React.FunctionComponent = ({ const invalidateApiKeyPrompt: InvalidateApiKeys = (keys, onSuccess = () => undefined) => { if (!keys || !keys.length) { - throw new Error('No API key IDs specified for invalidation'); + throw new Error('No API key IDs specified for deletion'); } setIsModalOpen(true); setApiKeys(keys); @@ -75,16 +75,16 @@ export const InvalidateProvider: React.FunctionComponent = ({ const hasMultipleSuccesses = itemsInvalidated.length > 1; const successMessage = hasMultipleSuccesses ? i18n.translate( - 'xpack.security.management.apiKeys.invalidateApiKey.successMultipleNotificationTitle', + 'xpack.security.management.apiKeys.deleteApiKey.successMultipleNotificationTitle', { - defaultMessage: 'Invalidated {count} API keys', + defaultMessage: 'Deleted {count} API keys', values: { count: itemsInvalidated.length }, } ) : i18n.translate( - 'xpack.security.management.apiKeys.invalidateApiKey.successSingleNotificationTitle', + 'xpack.security.management.apiKeys.deleteApiKey.successSingleNotificationTitle', { - defaultMessage: "Invalidated API key '{name}'", + defaultMessage: "Deleted API key '{name}'", values: { name: itemsInvalidated[0].name }, } ); @@ -102,7 +102,7 @@ export const InvalidateProvider: React.FunctionComponent = ({ const hasMultipleErrors = (errors && errors.length > 1) || (error && apiKeys.length > 1); const errorMessage = hasMultipleErrors ? i18n.translate( - 'xpack.security.management.apiKeys.invalidateApiKey.errorMultipleNotificationTitle', + 'xpack.security.management.apiKeys.deleteApiKey.errorMultipleNotificationTitle', { defaultMessage: 'Error deleting {count} apiKeys', values: { @@ -111,7 +111,7 @@ export const InvalidateProvider: React.FunctionComponent = ({ } ) : i18n.translate( - 'xpack.security.management.apiKeys.invalidateApiKey.errorSingleNotificationTitle', + 'xpack.security.management.apiKeys.deleteApiKey.errorSingleNotificationTitle', { defaultMessage: "Error deleting API key '{name}'", values: { name: (errors && errors[0].name) || apiKeys[0].name }, @@ -130,19 +130,20 @@ export const InvalidateProvider: React.FunctionComponent = ({ return ( = ({ onCancel={closeModal} onConfirm={invalidateApiKey} cancelButtonText={i18n.translate( - 'xpack.security.management.apiKeys.invalidateApiKey.confirmModal.cancelButtonLabel', + 'xpack.security.management.apiKeys.deleteApiKey.confirmModal.cancelButtonLabel', { defaultMessage: 'Cancel' } )} confirmButtonText={i18n.translate( - 'xpack.security.management.apiKeys.invalidateApiKey.confirmModal.confirmButtonLabel', + 'xpack.security.management.apiKeys.deleteApiKey.confirmModal.confirmButtonLabel', { - defaultMessage: 'Invalidate {count, plural, one {API key} other {API keys}}', + defaultMessage: 'Delete {count, plural, one {API key} other {API keys}}', values: { count: apiKeys.length }, } )} @@ -167,8 +168,8 @@ export const InvalidateProvider: React.FunctionComponent = ({

{i18n.translate( - 'xpack.security.management.apiKeys.invalidateApiKey.confirmModal.invalidateMultipleListDescription', - { defaultMessage: 'You are about to invalidate these API keys:' } + 'xpack.security.management.apiKeys.deleteApiKey.confirmModal.deleteMultipleListDescription', + { defaultMessage: 'You are about to delete these API keys:' } )}

    diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx index bada8c5c7ce4cf..d2611864e77a2d 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx @@ -6,29 +6,36 @@ */ jest.mock('./api_keys_grid', () => ({ - APIKeysGridPage: (props: any) => `Page: ${JSON.stringify(props)}`, + APIKeysGridPage: (props: any) => JSON.stringify(props, null, 2), })); + +import { act } from '@testing-library/react'; + import { coreMock, scopedHistoryMock } from 'src/core/public/mocks'; +import type { Unmount } from 'src/plugins/management/public/types'; +import { securityMock } from '../../mocks'; import { apiKeysManagementApp } from './api_keys_management_app'; describe('apiKeysManagementApp', () => { it('create() returns proper management app descriptor', () => { const { getStartServices } = coreMock.createSetup(); + const { authc } = securityMock.createSetup(); - expect(apiKeysManagementApp.create({ getStartServices: getStartServices as any })) + expect(apiKeysManagementApp.create({ authc, getStartServices: getStartServices as any })) .toMatchInlineSnapshot(` Object { "id": "api_keys", "mount": [Function], "order": 30, - "title": "API Keys", + "title": "API keys", } `); }); it('mount() works for the `grid` page', async () => { const { getStartServices } = coreMock.createSetup(); + const { authc } = securityMock.createSetup(); const startServices = await getStartServices(); const docTitle = startServices[0].chrome.docTitle; @@ -36,28 +43,54 @@ describe('apiKeysManagementApp', () => { const container = document.createElement('div'); const setBreadcrumbs = jest.fn(); - const unmount = await apiKeysManagementApp - .create({ getStartServices: () => Promise.resolve(startServices) as any }) - .mount({ - basePath: '/some-base-path', - element: container, - setBreadcrumbs, - history: scopedHistoryMock.create(), - }); + let unmount: Unmount; + await act(async () => { + unmount = await apiKeysManagementApp + .create({ authc, getStartServices: () => Promise.resolve(startServices) as any }) + .mount({ + basePath: '/some-base-path', + element: container, + setBreadcrumbs, + history: scopedHistoryMock.create(), + }); + }); expect(setBreadcrumbs).toHaveBeenCalledTimes(1); - expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: '/', text: 'API Keys' }]); - expect(docTitle.change).toHaveBeenCalledWith('API Keys'); + expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: '/', text: 'API keys' }]); + expect(docTitle.change).toHaveBeenCalledWith(['API keys']); expect(docTitle.reset).not.toHaveBeenCalled(); expect(container).toMatchInlineSnapshot(`
    - Page: {"notifications":{"toasts":{}},"apiKeysAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}}} + { + "history": { + "action": "PUSH", + "length": 1, + "location": { + "pathname": "/", + "search": "", + "hash": "" + } + }, + "notifications": { + "toasts": {} + }, + "apiKeysAPIClient": { + "http": { + "basePath": { + "basePath": "", + "serverBasePath": "" + }, + "anonymousPaths": {}, + "externalUrl": {} + } + } + }
    `); - unmount(); - expect(docTitle.reset).toHaveBeenCalledTimes(1); + unmount!(); + expect(docTitle.reset).toHaveBeenCalledTimes(1); expect(container).toMatchInlineSnapshot(`
    `); }); }); diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx index 8fa52ba7e2edd2..68e06d38db4c86 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx @@ -5,63 +5,101 @@ * 2.0. */ +import type { History } from 'history'; +import type { FunctionComponent } from 'react'; import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; +import { Router } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; -import type { StartServicesAccessor } from 'src/core/public'; -import type { RegisterManagementAppArgs } from 'src/plugins/management/public'; +import { I18nProvider } from '@kbn/i18n/react'; +import type { CoreStart, StartServicesAccessor } from '../../../../../../src/core/public'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; +import type { RegisterManagementAppArgs } from '../../../../../../src/plugins/management/public'; +import type { AuthenticationServiceSetup } from '../../authentication'; +import type { BreadcrumbsChangeHandler } from '../../components/breadcrumb'; +import { + Breadcrumb, + BreadcrumbsProvider, + createBreadcrumbsChangeHandler, +} from '../../components/breadcrumb'; +import { AuthenticationProvider } from '../../components/use_current_user'; import type { PluginStartDependencies } from '../../plugin'; interface CreateParams { + authc: AuthenticationServiceSetup; getStartServices: StartServicesAccessor; } export const apiKeysManagementApp = Object.freeze({ id: 'api_keys', - create({ getStartServices }: CreateParams) { - const title = i18n.translate('xpack.security.management.apiKeysTitle', { - defaultMessage: 'API Keys', - }); + create({ authc, getStartServices }: CreateParams) { return { id: this.id, order: 30, - title, - async mount({ element, setBreadcrumbs }) { - setBreadcrumbs([ - { - text: title, - href: `/`, - }, - ]); - - const [[core], { APIKeysGridPage }, { APIKeysAPIClient }] = await Promise.all([ + title: i18n.translate('xpack.security.management.apiKeysTitle', { + defaultMessage: 'API keys', + }), + async mount({ element, setBreadcrumbs, history }) { + const [[coreStart], { APIKeysGridPage }, { APIKeysAPIClient }] = await Promise.all([ getStartServices(), import('./api_keys_grid'), import('./api_keys_api_client'), ]); - core.chrome.docTitle.change(title); - render( - - + + - - , + + , element ); return () => { - core.chrome.docTitle.reset(); unmountComponentAtNode(element); }; }, } as RegisterManagementAppArgs; }, }); + +export interface ProvidersProps { + services: CoreStart; + history: History; + authc: AuthenticationServiceSetup; + onChange?: BreadcrumbsChangeHandler; +} + +export const Providers: FunctionComponent = ({ + services, + history, + authc, + onChange, + children, +}) => ( + + + + + {children} + + + + +); diff --git a/x-pack/plugins/security/public/management/management_service.test.ts b/x-pack/plugins/security/public/management/management_service.test.ts index 694f3cc3880a21..b21897377d5eb2 100644 --- a/x-pack/plugins/security/public/management/management_service.test.ts +++ b/x-pack/plugins/security/public/management/management_service.test.ts @@ -68,7 +68,7 @@ describe('ManagementService', () => { id: 'api_keys', mount: expect.any(Function), order: 30, - title: 'API Keys', + title: 'API keys', }); expect(mockSection.registerApp).toHaveBeenCalledWith({ id: 'role_mappings', diff --git a/x-pack/plugins/security/public/management/management_service.ts b/x-pack/plugins/security/public/management/management_service.ts index 7809a45db16605..af1b05e64e37c9 100644 --- a/x-pack/plugins/security/public/management/management_service.ts +++ b/x-pack/plugins/security/public/management/management_service.ts @@ -47,7 +47,7 @@ export class ManagementService { this.securitySection.registerApp( rolesManagementApp.create({ fatalErrors, license, getStartServices }) ); - this.securitySection.registerApp(apiKeysManagementApp.create({ getStartServices })); + this.securitySection.registerApp(apiKeysManagementApp.create({ authc, getStartServices })); this.securitySection.registerApp(roleMappingsManagementApp.create({ getStartServices })); } diff --git a/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.tsx b/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.tsx index 01b387c9e1fc28..445d424adb3882 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.tsx @@ -28,6 +28,7 @@ import { FormFlyout } from '../../../components/form_flyout'; import { useCurrentUser } from '../../../components/use_current_user'; import type { ValidationErrors } from '../../../components/use_form'; import { useForm } from '../../../components/use_form'; +import { useInitialFocus } from '../../../components/use_initial_focus'; import { UserAPIClient } from '../user_api_client'; export interface ChangePasswordFormValues { @@ -147,6 +148,8 @@ export const ChangePasswordFlyout: FunctionComponent defaultValues, }); + const firstFieldRef = useInitialFocus([isLoading]); + return ( defaultValue={form.values.current_password} isInvalid={form.touched.current_password && !!form.errors.current_password} autoComplete="current-password" + inputRef={firstFieldRef} /> ) : null} @@ -263,6 +267,7 @@ export const ChangePasswordFlyout: FunctionComponent defaultValue={form.values.password} isInvalid={form.touched.password && !!form.errors.password} autoComplete="new-password" + inputRef={isCurrentUser ? undefined : firstFieldRef} /> = ({ }, [services.http]); return ( - = ({ values: { count: usernames.length, isLoading: state.loading }, } )} - confirmButtonColor="danger" + buttonColor="danger" isLoading={state.loading} > @@ -94,6 +100,6 @@ export const ConfirmDeleteUsers: FunctionComponent = ({ />

    -
    + ); }; diff --git a/x-pack/plugins/security/public/management/users/edit_user/confirm_disable_users.tsx b/x-pack/plugins/security/public/management/users/edit_user/confirm_disable_users.tsx index a3d36e19504e1b..e8779a3bb59b94 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/confirm_disable_users.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/confirm_disable_users.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiText } from '@elastic/eui'; +import { EuiConfirmModal, EuiText } from '@elastic/eui'; import type { FunctionComponent } from 'react'; import React from 'react'; import useAsyncFn from 'react-use/lib/useAsyncFn'; @@ -13,9 +13,8 @@ import useAsyncFn from 'react-use/lib/useAsyncFn'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { UserAPIClient } from '..'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { ConfirmModal } from '../../../components/confirm_modal'; -import { UserAPIClient } from '../user_api_client'; export interface ConfirmDisableUsersProps { usernames: string[]; @@ -58,13 +57,20 @@ export const ConfirmDisableUsers: FunctionComponent = }, [services.http]); return ( - = values: { count: usernames.length, isLoading: state.loading }, }) } - confirmButtonColor={isSystemUser ? 'danger' : undefined} + buttonColor={isSystemUser ? 'danger' : undefined} isLoading={state.loading} > {isSystemUser ? ( @@ -89,7 +95,7 @@ export const ConfirmDisableUsers: FunctionComponent =

    @@ -117,6 +123,6 @@ export const ConfirmDisableUsers: FunctionComponent = )} )} - + ); }; diff --git a/x-pack/plugins/security/public/management/users/edit_user/confirm_enable_users.tsx b/x-pack/plugins/security/public/management/users/edit_user/confirm_enable_users.tsx index 24364d7b56d99c..68c9a645eaa9a1 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/confirm_enable_users.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/confirm_enable_users.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiText } from '@elastic/eui'; +import { EuiConfirmModal, EuiText } from '@elastic/eui'; import type { FunctionComponent } from 'react'; import React from 'react'; import useAsyncFn from 'react-use/lib/useAsyncFn'; @@ -13,9 +13,8 @@ import useAsyncFn from 'react-use/lib/useAsyncFn'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { UserAPIClient } from '..'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { ConfirmModal } from '../../../components/confirm_modal'; -import { UserAPIClient } from '../user_api_client'; export interface ConfirmEnableUsersProps { usernames: string[]; @@ -54,13 +53,20 @@ export const ConfirmEnableUsers: FunctionComponent = ({ }, [services.http]); return ( - = ({

)} - +
); }; diff --git a/x-pack/plugins/security/public/management/users/users_management_app.tsx b/x-pack/plugins/security/public/management/users/users_management_app.tsx index 3e18734cbf3683..f6a2956c7ad43f 100644 --- a/x-pack/plugins/security/public/management/users/users_management_app.tsx +++ b/x-pack/plugins/security/public/management/users/users_management_app.tsx @@ -20,7 +20,11 @@ import type { RegisterManagementAppArgs } from 'src/plugins/management/public'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; import type { AuthenticationServiceSetup } from '../../authentication'; import type { BreadcrumbsChangeHandler } from '../../components/breadcrumb'; -import { Breadcrumb, BreadcrumbsProvider, getDocTitle } from '../../components/breadcrumb'; +import { + Breadcrumb, + BreadcrumbsProvider, + createBreadcrumbsChangeHandler, +} from '../../components/breadcrumb'; import { AuthenticationProvider } from '../../components/use_current_user'; import type { PluginStartDependencies } from '../../plugin'; import { tryDecodeURIComponent } from '../url_utils'; @@ -64,10 +68,7 @@ export const usersManagementApp = Object.freeze({ services={coreStart} history={history} authc={authc} - onChange={(breadcrumbs) => { - setBreadcrumbs(breadcrumbs); - coreStart.chrome.docTitle.change(getDocTitle(breadcrumbs)); - }} + onChange={createBreadcrumbsChangeHandler(coreStart.chrome, setBreadcrumbs)} > { + function getMockContext( + licenseCheckResult: { state: string; message?: string } = { state: 'valid' } + ) { + return ({ + licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } }, + } as unknown) as SecurityRequestHandlerContext; + } + + let routeHandler: RequestHandler; + let authc: DeeplyMockedKeys; + beforeEach(() => { + authc = authenticationServiceMock.createStart(); + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + mockRouteDefinitionParams.getAuthenticationService.mockReturnValue(authc); + + defineCreateApiKeyRoutes(mockRouteDefinitionParams); + + const [, apiKeyRouteHandler] = mockRouteDefinitionParams.router.post.mock.calls.find( + ([{ path }]) => path === '/internal/security/api_key' + )!; + routeHandler = apiKeyRouteHandler; + }); + + describe('failure', () => { + test('returns result of license checker', async () => { + const mockContext = getMockContext({ state: 'invalid', message: 'test forbidden message' }); + const response = await routeHandler( + mockContext, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ); + + expect(response.status).toBe(403); + expect(response.payload).toEqual({ message: 'test forbidden message' }); + expect(mockContext.licensing.license.check).toHaveBeenCalledWith('security', 'basic'); + }); + + test('returns error from cluster client', async () => { + const error = Boom.notAcceptable('test not acceptable message'); + authc.apiKeys.create.mockRejectedValue(error); + + const response = await routeHandler( + getMockContext(), + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ); + + expect(response.status).toBe(406); + expect(response.payload).toEqual(error); + }); + }); + + describe('success', () => { + test('allows an API Key to be created', async () => { + authc.apiKeys.create.mockResolvedValue({ + api_key: 'abc123', + id: 'key_id', + name: 'my api key', + }); + + const payload = { + name: 'my api key', + expires: '12d', + role_descriptors: { + role_1: {}, + }, + }; + + const request = httpServerMock.createKibanaRequest({ + body: { + ...payload, + }, + }); + + const response = await routeHandler(getMockContext(), request, kibanaResponseFactory); + expect(authc.apiKeys.create).toHaveBeenCalledWith(request, payload); + + expect(response.status).toBe(200); + expect(response.payload).toEqual({ + api_key: 'abc123', + id: 'key_id', + name: 'my api key', + }); + }); + + test('returns a message if API Keys are disabled', async () => { + authc.apiKeys.create.mockResolvedValue(null); + + const payload = { + name: 'my api key', + expires: '12d', + role_descriptors: { + role_1: {}, + }, + }; + + const request = httpServerMock.createKibanaRequest({ + body: { + ...payload, + }, + }); + + const response = await routeHandler(getMockContext(), request, kibanaResponseFactory); + expect(authc.apiKeys.create).toHaveBeenCalledWith(request, payload); + + expect(response.status).toBe(400); + expect(response.payload).toEqual({ + message: 'API Keys are not available', + }); + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/api_keys/create.ts b/x-pack/plugins/security/server/routes/api_keys/create.ts new file mode 100644 index 00000000000000..a309d3a0e3edba --- /dev/null +++ b/x-pack/plugins/security/server/routes/api_keys/create.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import type { RouteDefinitionParams } from '..'; +import { wrapIntoCustomErrorResponse } from '../../errors'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; + +export function defineCreateApiKeyRoutes({ + router, + getAuthenticationService, +}: RouteDefinitionParams) { + router.post( + { + path: '/internal/security/api_key', + validate: { + body: schema.object({ + name: schema.string(), + expiration: schema.maybe(schema.string()), + role_descriptors: schema.recordOf( + schema.string(), + schema.object({}, { unknowns: 'allow' }), + { + defaultValue: {}, + } + ), + }), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + try { + const apiKey = await getAuthenticationService().apiKeys.create(request, request.body); + + if (!apiKey) { + return response.badRequest({ body: { message: `API Keys are not available` } }); + } + + return response.ok({ body: apiKey }); + } catch (error) { + return response.customError(wrapIntoCustomErrorResponse(error)); + } + }) + ); +} diff --git a/x-pack/plugins/security/server/routes/api_keys/index.ts b/x-pack/plugins/security/server/routes/api_keys/index.ts index e6a8711bdf19e6..aa1e3b858ea582 100644 --- a/x-pack/plugins/security/server/routes/api_keys/index.ts +++ b/x-pack/plugins/security/server/routes/api_keys/index.ts @@ -6,6 +6,7 @@ */ import type { RouteDefinitionParams } from '../'; +import { defineCreateApiKeyRoutes } from './create'; import { defineEnabledApiKeysRoutes } from './enabled'; import { defineGetApiKeysRoutes } from './get'; import { defineInvalidateApiKeysRoutes } from './invalidate'; @@ -14,6 +15,7 @@ import { defineCheckPrivilegesRoutes } from './privileges'; export function defineApiKeysRoutes(params: RouteDefinitionParams) { defineEnabledApiKeysRoutes(params); defineGetApiKeysRoutes(params); + defineCreateApiKeyRoutes(params); defineCheckPrivilegesRoutes(params); defineInvalidateApiKeysRoutes(params); } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 527f32828979ac..8f71353113f5f0 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -17385,7 +17385,6 @@ "xpack.security.components.sessionIdleTimeoutWarning.title": "警告", "xpack.security.components.sessionLifespanWarning.message": "セッションは最大時間制限{timeout}に達しました。もう一度ログインする必要があります。", "xpack.security.components.sessionLifespanWarning.title": "警告", - "xpack.security.confirmModal.cancelButton": "キャンセル", "xpack.security.conflictingSessionError": "申し訳ありません。すでに有効なKibanaセッションがあります。新しいセッションを開始する場合は、先に既存のセッションからログアウトしてください。", "xpack.security.formFlyout.cancelButton": "キャンセル", "xpack.security.loggedOut.login": "ログイン", @@ -17421,19 +17420,7 @@ "xpack.security.loginWithElasticsearchLabel": "Elasticsearchでログイン", "xpack.security.logoutAppTitle": "ログアウト", "xpack.security.management.apiKeys.deniedPermissionTitle": "API キーを管理するにはパーミッションが必要です", - "xpack.security.management.apiKeys.invalidateApiKey.confirmModal.cancelButtonLabel": "キャンセル", - "xpack.security.management.apiKeys.invalidateApiKey.confirmModal.invalidateMultipleListDescription": "これらの API キーを無効化しようとしています:", - "xpack.security.management.apiKeys.invalidateApiKey.confirmModal.invalidateMultipleTitle": "{count} API キーを無効にしますか?", - "xpack.security.management.apiKeys.invalidateApiKey.confirmModal.invalidateSingleTitle": "API キー「{name}」を無効にしますか?", - "xpack.security.management.apiKeys.invalidateApiKey.errorMultipleNotificationTitle": "{count} 件の API キーの削除中にエラーが発生", - "xpack.security.management.apiKeys.invalidateApiKey.errorSingleNotificationTitle": "API キー「{name}」の削除中にエラーが発生", - "xpack.security.management.apiKeys.invalidateApiKey.successMultipleNotificationTitle": "無効な {count} API キー", - "xpack.security.management.apiKeys.invalidateApiKey.successSingleNotificationTitle": "API キー「{name}」を無効にしました", "xpack.security.management.apiKeys.noPermissionToManageRolesDescription": "システム管理者にお問い合わせください。", - "xpack.security.management.apiKeys.table.actionDeleteAriaLabel": "「{name}」を無効にする", - "xpack.security.management.apiKeys.table.actionDeleteTooltip": "無効にする", - "xpack.security.management.apiKeys.table.actionsColumnName": "アクション", - "xpack.security.management.apiKeys.table.adminText": "あなたは API キー管理者です。", "xpack.security.management.apiKeys.table.apiKeysAllDescription": "API キーを表示して無効にします。API キーはユーザーの代わりにリクエストを送信します。", "xpack.security.management.apiKeys.table.apiKeysDisabledErrorDescription": "システム管理者に連絡し、{link}を参照して API キーを有効にしてください。", "xpack.security.management.apiKeys.table.apiKeysDisabledErrorLinkText": "ドキュメント", @@ -17442,20 +17429,10 @@ "xpack.security.management.apiKeys.table.apiKeysTableLoadingMessage": "API キーを読み込み中…", "xpack.security.management.apiKeys.table.apiKeysTitle": "API キー", "xpack.security.management.apiKeys.table.creationDateColumnName": "作成済み", - "xpack.security.management.apiKeys.table.emptyPromptAdminTitle": "API キーがありません", - "xpack.security.management.apiKeys.table.emptyPromptConsoleButtonMessage": "コンソールに移動してください", - "xpack.security.management.apiKeys.table.emptyPromptDescription": "コンソールで {link} を作成できます。", - "xpack.security.management.apiKeys.table.emptyPromptDocsLinkMessage": "API キー", - "xpack.security.management.apiKeys.table.emptyPromptNonAdminTitle": "まだ API キーがありません", - "xpack.security.management.apiKeys.table.expirationDateColumnName": "有効期限", - "xpack.security.management.apiKeys.table.expirationDateNeverMessage": "なし", "xpack.security.management.apiKeys.table.fetchingApiKeysErrorMessage": "権限の確認エラー:{message}", "xpack.security.management.apiKeys.table.loadingApiKeysDescription": "API キーを読み込み中…", - "xpack.security.management.apiKeys.table.loadingApiKeysErrorTitle": "API キーを読み込み中にエラーが発生", "xpack.security.management.apiKeys.table.nameColumnName": "名前", - "xpack.security.management.apiKeys.table.realmColumnName": "レルム", "xpack.security.management.apiKeys.table.realmFilterLabel": "レルム", - "xpack.security.management.apiKeys.table.reloadApiKeysButton": "再読み込み", "xpack.security.management.apiKeys.table.statusColumnName": "ステータス", "xpack.security.management.apiKeys.table.userFilterLabel": "ユーザー", "xpack.security.management.apiKeys.table.userNameColumnName": "ユーザー", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index f8c8ee753942cd..7269615c051db8 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -17625,7 +17625,6 @@ "xpack.security.components.sessionIdleTimeoutWarning.title": "警告", "xpack.security.components.sessionLifespanWarning.message": "您的会话将达到最大时间限制 {timeout}。您将需要重新登录。", "xpack.security.components.sessionLifespanWarning.title": "警告", - "xpack.security.confirmModal.cancelButton": "取消", "xpack.security.conflictingSessionError": "抱歉,您已有活动的 Kibana 会话。如果希望开始新的会话,请首先从现有会话注销。", "xpack.security.formFlyout.cancelButton": "取消", "xpack.security.loggedOut.login": "登录", @@ -17661,20 +17660,7 @@ "xpack.security.loginWithElasticsearchLabel": "通过 Elasticsearch 登录", "xpack.security.logoutAppTitle": "注销", "xpack.security.management.apiKeys.deniedPermissionTitle": "您需要管理 API 密钥的权限", - "xpack.security.management.apiKeys.invalidateApiKey.confirmModal.cancelButtonLabel": "取消", - "xpack.security.management.apiKeys.invalidateApiKey.confirmModal.confirmButtonLabel": "作废 {count, plural, other {API 密钥}}", - "xpack.security.management.apiKeys.invalidateApiKey.confirmModal.invalidateMultipleListDescription": "您即将作废以下 API 密钥:", - "xpack.security.management.apiKeys.invalidateApiKey.confirmModal.invalidateMultipleTitle": "作废 {count} 个 API 密钥?", - "xpack.security.management.apiKeys.invalidateApiKey.confirmModal.invalidateSingleTitle": "作废 API 密钥“{name}”?", - "xpack.security.management.apiKeys.invalidateApiKey.errorMultipleNotificationTitle": "删除 {count} 个 API 密钥时出错", - "xpack.security.management.apiKeys.invalidateApiKey.errorSingleNotificationTitle": "删除 API 密钥“{name}”时出错", - "xpack.security.management.apiKeys.invalidateApiKey.successMultipleNotificationTitle": "已作废 {count} 个 API 密钥", - "xpack.security.management.apiKeys.invalidateApiKey.successSingleNotificationTitle": "已作废 API 密钥“{name}”", "xpack.security.management.apiKeys.noPermissionToManageRolesDescription": "请联系您的系统管理员。", - "xpack.security.management.apiKeys.table.actionDeleteAriaLabel": "作废“{name}”", - "xpack.security.management.apiKeys.table.actionDeleteTooltip": "作废", - "xpack.security.management.apiKeys.table.actionsColumnName": "操作", - "xpack.security.management.apiKeys.table.adminText": "您是 API 密钥管理员。", "xpack.security.management.apiKeys.table.apiKeysAllDescription": "查看并作废 API 密钥。API 密钥代表用户发送请求。", "xpack.security.management.apiKeys.table.apiKeysDisabledErrorDescription": "请联系您的系统管理员并参阅{link}以启用 API 密钥。", "xpack.security.management.apiKeys.table.apiKeysDisabledErrorLinkText": "文档", @@ -17683,21 +17669,11 @@ "xpack.security.management.apiKeys.table.apiKeysTableLoadingMessage": "正在加载 API 密钥……", "xpack.security.management.apiKeys.table.apiKeysTitle": "API 密钥", "xpack.security.management.apiKeys.table.creationDateColumnName": "已创建", - "xpack.security.management.apiKeys.table.emptyPromptAdminTitle": "无 API 密钥", - "xpack.security.management.apiKeys.table.emptyPromptConsoleButtonMessage": "前往 Console", - "xpack.security.management.apiKeys.table.emptyPromptDescription": "您可以从 Console 创建 {link}。", - "xpack.security.management.apiKeys.table.emptyPromptDocsLinkMessage": "API 密钥", - "xpack.security.management.apiKeys.table.emptyPromptNonAdminTitle": "您未有任何 API 密钥", - "xpack.security.management.apiKeys.table.expirationDateColumnName": "过期", - "xpack.security.management.apiKeys.table.expirationDateNeverMessage": "永不", "xpack.security.management.apiKeys.table.fetchingApiKeysErrorMessage": "检查权限时出错:{message}", "xpack.security.management.apiKeys.table.invalidateApiKeyButton": "作废 {count, plural, other {API 密钥}}", "xpack.security.management.apiKeys.table.loadingApiKeysDescription": "正在加载 API 密钥……", - "xpack.security.management.apiKeys.table.loadingApiKeysErrorTitle": "加载 API 密钥时出错", "xpack.security.management.apiKeys.table.nameColumnName": "名称", - "xpack.security.management.apiKeys.table.realmColumnName": "Realm", "xpack.security.management.apiKeys.table.realmFilterLabel": "Realm", - "xpack.security.management.apiKeys.table.reloadApiKeysButton": "重新加载", "xpack.security.management.apiKeys.table.statusColumnName": "状态", "xpack.security.management.apiKeys.table.userFilterLabel": "用户", "xpack.security.management.apiKeys.table.userNameColumnName": "用户", diff --git a/x-pack/test/api_integration/apis/security/api_keys.ts b/x-pack/test/api_integration/apis/security/api_keys.ts index 596a0b038cfb3f..c6513fa800c1c2 100644 --- a/x-pack/test/api_integration/apis/security/api_keys.ts +++ b/x-pack/test/api_integration/apis/security/api_keys.ts @@ -25,5 +25,27 @@ export default function ({ getService }: FtrProviderContext) { }); }); }); + + describe('POST /internal/security/api_key', () => { + it('should allow an API Key to be created', async () => { + await supertest + .post('/internal/security/api_key') + .set('kbn-xsrf', 'xxx') + .send({ + name: 'test_api_key', + expiration: '12d', + role_descriptors: { + role_1: { + cluster: ['monitor'], + }, + }, + }) + .expect(200) + .then((response: Record) => { + const { name } = response.body; + expect(name).to.eql('test_api_key'); + }); + }); + }); }); } diff --git a/x-pack/test/functional/apps/api_keys/home_page.ts b/x-pack/test/functional/apps/api_keys/home_page.ts index 6191a2b8dbcfc5..be8f128359345f 100644 --- a/x-pack/test/functional/apps/api_keys/home_page.ts +++ b/x-pack/test/functional/apps/api_keys/home_page.ts @@ -5,7 +5,6 @@ * 2.0. */ -import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ getPageObjects, getService }: FtrProviderContext) => { @@ -13,6 +12,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const log = getService('log'); const security = getService('security'); const testSubjects = getService('testSubjects'); + const find = getService('find'); describe('Home page', function () { before(async () => { @@ -31,17 +31,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('Loads the app', async () => { await security.testUser.setRoles(['test_api_keys']); - log.debug('Checking for section header'); - const headers = await testSubjects.findAll('noApiKeysHeader'); - if (headers.length > 0) { - expect(await headers[0].getVisibleText()).to.be('No API keys'); - const goToConsoleButton = await pageObjects.apiKeys.getGoToConsoleButton(); - expect(await goToConsoleButton.isDisplayed()).to.be(true); - } else { - // page may already contain EiTable with data, then check API Key Admin text - const description = await pageObjects.apiKeys.getApiKeyAdminDesc(); - expect(description).to.be('You are an API Key administrator.'); - } + log.debug('Checking for create API key call to action'); + await find.existsByLinkText('Create API key'); }); }); }; From 6ddc4bff069a80b9afe81f7555a81cc9ba72d315 Mon Sep 17 00:00:00 2001 From: Uladzislau Lasitsa Date: Tue, 13 Apr 2021 14:32:11 +0300 Subject: [PATCH 16/90] [TSVB] Wrong custom values formatting for the empty buckets (#96293) * Don't apply formatter for default value * Remove the logic to overwrite the default value because it is not being used * Fix remark Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- src/plugins/vis_type_timeseries/common/get_last_value.js | 9 +++++---- .../vis_type_timeseries/common/get_last_value.test.js | 4 ---- .../public/application/components/lib/tick_formatter.js | 6 ++++++ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/plugins/vis_type_timeseries/common/get_last_value.js b/src/plugins/vis_type_timeseries/common/get_last_value.js index 5a36a5e099f9d0..80adf7098f24dd 100644 --- a/src/plugins/vis_type_timeseries/common/get_last_value.js +++ b/src/plugins/vis_type_timeseries/common/get_last_value.js @@ -8,13 +8,14 @@ import { isArray, last } from 'lodash'; -const DEFAULT_VALUE = '-'; +export const DEFAULT_VALUE = '-'; + const extractValue = (data) => (data && data[1]) ?? null; -export const getLastValue = (data, defaultValue = DEFAULT_VALUE) => { +export const getLastValue = (data) => { if (!isArray(data)) { - return data ?? defaultValue; + return data ?? DEFAULT_VALUE; } - return extractValue(last(data)) ?? defaultValue; + return extractValue(last(data)) ?? DEFAULT_VALUE; }; diff --git a/src/plugins/vis_type_timeseries/common/get_last_value.test.js b/src/plugins/vis_type_timeseries/common/get_last_value.test.js index 122f037ddf3e47..794bbe17a1e7a1 100644 --- a/src/plugins/vis_type_timeseries/common/get_last_value.test.js +++ b/src/plugins/vis_type_timeseries/common/get_last_value.test.js @@ -37,8 +37,4 @@ describe('getLastValue(data)', () => { ]) ).toBe('-'); }); - - test('should allows to override the default value', () => { - expect(getLastValue(null, 'default')).toBe('default'); - }); }); diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/tick_formatter.js b/src/plugins/vis_type_timeseries/public/application/components/lib/tick_formatter.js index c9c0e0b3f43a34..ac4780e673e07a 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/lib/tick_formatter.js +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/tick_formatter.js @@ -8,6 +8,7 @@ import handlebars from 'handlebars/dist/handlebars'; import { isNumber } from 'lodash'; +import { DEFAULT_VALUE } from '../../../../common/get_last_value'; import { inputFormats, outputFormats, isDuration } from '../lib/durations'; import { getFieldFormats } from '../../../services'; @@ -38,6 +39,11 @@ export const createTickFormatter = (format = '0,0.[00]', template, getConfig = n } return (val) => { let value; + + if (val === DEFAULT_VALUE) { + return val; + } + if (!isNumber(val)) { value = val; } else { From d8b4316783dea8450d6fbd298e88359f3de65002 Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm Date: Tue, 13 Apr 2021 14:12:19 +0200 Subject: [PATCH 17/90] [Discover] Close inspector when switching app (#92994) --- .../public/application/components/discover.tsx | 17 ++++++++++++++++- .../application/components/discover_topnav.tsx | 1 - .../top_nav/get_top_nav_links.test.ts | 2 -- .../components/top_nav/get_top_nav_links.ts | 6 ------ 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/plugins/discover/public/application/components/discover.tsx b/src/plugins/discover/public/application/components/discover.tsx index 6b71bd892b5208..0df921dc99ad73 100644 --- a/src/plugins/discover/public/application/components/discover.tsx +++ b/src/plugins/discover/public/application/components/discover.tsx @@ -43,6 +43,7 @@ import { DiscoverTopNav } from './discover_topnav'; import { ElasticSearchHit } from '../doc_views/doc_views_types'; import { setBreadcrumbsTitle } from '../helpers/breadcrumbs'; import { addHelpMenuToAppChrome } from './help_menu/help_menu_util'; +import { InspectorSession } from '../../../../inspector/public'; const DocTableLegacyMemoized = React.memo(DocTableLegacy); const SidebarMemoized = React.memo(DiscoverSidebarResponsive); @@ -71,6 +72,7 @@ export function Discover({ refreshAppState, }: DiscoverProps) { const [expandedDoc, setExpandedDoc] = useState(undefined); + const [inspectorSession, setInspectorSession] = useState(undefined); const scrollableDesktop = useRef(null); const collapseIcon = useRef(null); const isMobile = () => { @@ -131,7 +133,20 @@ export function Discover({ const onOpenInspector = useCallback(() => { // prevent overlapping setExpandedDoc(undefined); - }, [setExpandedDoc]); + const session = services.inspector.open(opts.inspectorAdapters, { + title: savedSearch.title, + }); + setInspectorSession(session); + }, [setExpandedDoc, opts.inspectorAdapters, savedSearch, services.inspector]); + + useEffect(() => { + return () => { + if (inspectorSession) { + // Close the inspector if this scope is destroyed (e.g. because the user navigates away). + inspectorSession.close(); + } + }; + }, [inspectorSession]); const onSort = useCallback( (sort: string[][]) => { diff --git a/src/plugins/discover/public/application/components/discover_topnav.tsx b/src/plugins/discover/public/application/components/discover_topnav.tsx index ee59ee13583bdd..c5c0df6e6f74a1 100644 --- a/src/plugins/discover/public/application/components/discover_topnav.tsx +++ b/src/plugins/discover/public/application/components/discover_topnav.tsx @@ -33,7 +33,6 @@ export const DiscoverTopNav = ({ getTopNavLinks({ getFieldCounts: opts.getFieldCounts, indexPattern, - inspectorAdapters: opts.inspectorAdapters, navigateTo: opts.navigateTo, savedSearch: opts.savedSearch, services: opts.services, diff --git a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.test.ts b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.test.ts index 30edb102c420aa..f6e9e70b337bae 100644 --- a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.test.ts +++ b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.test.ts @@ -8,7 +8,6 @@ import { ISearchSource } from 'src/plugins/data/public'; import { getTopNavLinks } from './get_top_nav_links'; -import { inspectorPluginMock } from '../../../../../inspector/public/mocks'; import { indexPatternMock } from '../../../__mocks__/index_pattern'; import { savedSearchMock } from '../../../__mocks__/saved_search'; import { DiscoverServices } from '../../../build_services'; @@ -28,7 +27,6 @@ test('getTopNavLinks result', () => { const topNavLinks = getTopNavLinks({ getFieldCounts: jest.fn(), indexPattern: indexPatternMock, - inspectorAdapters: inspectorPluginMock, navigateTo: jest.fn(), onOpenInspector: jest.fn(), savedSearch: savedSearchMock, diff --git a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts index 65fef2e4d030fc..635684177e1e36 100644 --- a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts +++ b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts @@ -11,7 +11,6 @@ import { showOpenSearchPanel } from './show_open_search_panel'; import { getSharingData, showPublicUrlSwitch } from '../../helpers/get_sharing_data'; import { unhashUrl } from '../../../../../kibana_utils/public'; import { DiscoverServices } from '../../../build_services'; -import { Adapters } from '../../../../../inspector/common/adapters'; import { SavedSearch } from '../../../saved_searches'; import { onSaveSearch } from './on_save_search'; import { GetStateReturn } from '../../angular/discover_state'; @@ -23,7 +22,6 @@ import { IndexPattern, ISearchSource } from '../../../kibana_services'; export const getTopNavLinks = ({ getFieldCounts, indexPattern, - inspectorAdapters, navigateTo, savedSearch, services, @@ -33,7 +31,6 @@ export const getTopNavLinks = ({ }: { getFieldCounts: () => Promise>; indexPattern: IndexPattern; - inspectorAdapters: Adapters; navigateTo: (url: string) => void; savedSearch: SavedSearch; services: DiscoverServices; @@ -127,9 +124,6 @@ export const getTopNavLinks = ({ testId: 'openInspectorButton', run: () => { onOpenInspector(); - services.inspector.open(inspectorAdapters, { - title: savedSearch.title, - }); }, }; From b9c4d248ae55f698ba375777bb22e15cb02101a4 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Tue, 13 Apr 2021 14:15:34 +0200 Subject: [PATCH 18/90] [ESUI] More robust handling of error responses (#96819) * more robust handling of error responses * added tests and further hardening of how we handle error values --- .../errors/handle_es_error.test.ts | 71 +++++++++++++++++++ .../errors/handle_es_error.ts | 8 ++- 2 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 src/plugins/es_ui_shared/__packages_do_not_import__/errors/handle_es_error.test.ts diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/errors/handle_es_error.test.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/errors/handle_es_error.test.ts new file mode 100644 index 00000000000000..cff179f64ea081 --- /dev/null +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/errors/handle_es_error.test.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { errors } from '@elastic/elasticsearch'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { kibanaResponseFactory as response } from 'src/core/server'; +import { handleEsError } from './handle_es_error'; + +const { ResponseError } = errors; + +const anyObject: any = {}; + +describe('handleEsError', () => { + test('top-level reason is an empty string', () => { + const emptyReasonError = new ResponseError({ + warnings: [], + meta: anyObject, + body: { + error: { + root_cause: [], + type: 'search_phase_execution_exception', + reason: '', // Empty reason + phase: 'fetch', + grouped: true, + failed_shards: [], + caused_by: { + type: 'too_many_buckets_exception', + reason: 'This is the nested reason', + max_buckets: 100, + }, + }, + }, + statusCode: 503, + headers: {}, + }); + + const { payload, status } = handleEsError({ error: emptyReasonError, response }); + + expect(payload.message).toEqual('This is the nested reason'); + expect(status).toBe(503); + }); + + test('empty error', () => { + const { payload, status } = handleEsError({ + error: new ResponseError({ + body: {}, + statusCode: 400, + headers: {}, + meta: anyObject, + warnings: [], + }), + response, + }); + + expect(payload).toEqual({ + attributes: { causes: undefined, error: undefined }, + message: 'Response Error', + }); + + expect(status).toBe(400); + }); + + test('unknown object', () => { + expect(() => handleEsError({ error: anyObject, response })).toThrow(); + }); +}); diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/errors/handle_es_error.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/errors/handle_es_error.ts index 6a308203fcc279..678c46f69d51fa 100644 --- a/src/plugins/es_ui_shared/__packages_do_not_import__/errors/handle_es_error.ts +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/errors/handle_es_error.ts @@ -38,12 +38,14 @@ export const handleEsError = ({ return response.customError({ statusCode, body: { - message: body.error?.reason ?? error.message ?? 'Unknown error', + message: + // We use || instead of ?? as the switch here because reason could be an empty string + body?.error?.reason || body?.error?.caused_by?.reason || error.message || 'Unknown error', attributes: { // The full original ES error object - error: body.error, + error: body?.error, // We assume that this is an ES error object with a nested caused by chain if we can see the "caused_by" field at the top-level - causes: body.error?.caused_by ? getEsCause(body.error) : undefined, + causes: body?.error?.caused_by ? getEsCause(body.error) : undefined, }, }, }); From bfd5b7bda69fde9154b8fc2f955eef5c32f25e33 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Tue, 13 Apr 2021 14:34:32 +0200 Subject: [PATCH 19/90] Exclude non-persisted sessions from SO migration (#96938) --- .../migrations/core/elastic_index.test.ts | 16 ++ .../migrations/core/elastic_index.ts | 57 +++++-- .../saved_objects/migrations/core/index.ts | 1 + .../migrationsv2/actions/index.ts | 15 +- .../integration_tests/actions.test.ts | 10 +- .../migrations_state_action_machine.test.ts | 152 +++++++++++++++--- .../saved_objects/migrationsv2/model.test.ts | 50 +++++- .../saved_objects/migrationsv2/model.ts | 9 +- .../server/saved_objects/migrationsv2/next.ts | 4 +- .../saved_objects/migrationsv2/types.ts | 5 +- 10 files changed, 254 insertions(+), 65 deletions(-) diff --git a/src/core/server/saved_objects/migrations/core/elastic_index.test.ts b/src/core/server/saved_objects/migrations/core/elastic_index.test.ts index 2fc78fc619cab5..1d2ec6abc0dd14 100644 --- a/src/core/server/saved_objects/migrations/core/elastic_index.test.ts +++ b/src/core/server/saved_objects/migrations/core/elastic_index.test.ts @@ -425,6 +425,22 @@ describe('ElasticIndex', () => { type: 'tsvb-validation-telemetry', }, }, + { + bool: { + must: [ + { + match: { + type: 'search-session', + }, + }, + { + match: { + 'search-session.persisted': false, + }, + }, + ], + }, + }, ], }, }, diff --git a/src/core/server/saved_objects/migrations/core/elastic_index.ts b/src/core/server/saved_objects/migrations/core/elastic_index.ts index 462425ff6e3e0e..460aabbc77415c 100644 --- a/src/core/server/saved_objects/migrations/core/elastic_index.ts +++ b/src/core/server/saved_objects/migrations/core/elastic_index.ts @@ -29,6 +29,46 @@ export interface FullIndexInfo { mappings: IndexMapping; } +// When migrating from the outdated index we use a read query which excludes +// saved objects which are no longer used. These saved objects will still be +// kept in the outdated index for backup purposes, but won't be availble in +// the upgraded index. +export const excludeUnusedTypesQuery: estypes.QueryContainer = { + bool: { + must_not: [ + // https://github.com/elastic/kibana/issues/91869 + { + term: { + type: 'fleet-agent-events', + }, + }, + // https://github.com/elastic/kibana/issues/95617 + { + term: { + type: 'tsvb-validation-telemetry', + }, + }, + // https://github.com/elastic/kibana/issues/96131 + { + bool: { + must: [ + { + match: { + type: 'search-session', + }, + }, + { + match: { + 'search-session.persisted': false, + }, + }, + ], + }, + }, + ], + }, +}; + /** * A slight enhancement to indices.get, that adds indexName, and validates that the * index mappings are somewhat what we expect. @@ -69,23 +109,6 @@ export function reader( const scroll = scrollDuration; let scrollId: string | undefined; - // When migrating from the outdated index we use a read query which excludes - // saved object types which are no longer used. These saved objects will - // still be kept in the outdated index for backup purposes, but won't be - // availble in the upgraded index. - const EXCLUDE_UNUSED_TYPES = [ - 'fleet-agent-events', // https://github.com/elastic/kibana/issues/91869 - 'tsvb-validation-telemetry', // https://github.com/elastic/kibana/issues/95617 - ]; - - const excludeUnusedTypesQuery = { - bool: { - must_not: EXCLUDE_UNUSED_TYPES.map((type) => ({ - term: { type }, - })), - }, - }; - const nextBatch = () => scrollId !== undefined ? client.scroll>({ diff --git a/src/core/server/saved_objects/migrations/core/index.ts b/src/core/server/saved_objects/migrations/core/index.ts index 322150e2b850eb..1e51983a0ffbdb 100644 --- a/src/core/server/saved_objects/migrations/core/index.ts +++ b/src/core/server/saved_objects/migrations/core/index.ts @@ -14,3 +14,4 @@ export type { LogFn, SavedObjectsMigrationLogger } from './migration_logger'; export type { MigrationResult, MigrationStatus } from './migration_coordinator'; export { createMigrationEsClient } from './migration_es_client'; export type { MigrationEsClient } from './migration_es_client'; +export { excludeUnusedTypesQuery } from './elastic_index'; diff --git a/src/core/server/saved_objects/migrationsv2/actions/index.ts b/src/core/server/saved_objects/migrationsv2/actions/index.ts index 9d6afbd3b0d87c..02d3f8e21a5106 100644 --- a/src/core/server/saved_objects/migrationsv2/actions/index.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/index.ts @@ -14,7 +14,6 @@ import { errors as EsErrors } from '@elastic/elasticsearch'; import type { ElasticsearchClientError, ResponseError } from '@elastic/elasticsearch/lib/errors'; import { pipe } from 'fp-ts/lib/pipeable'; import { flow } from 'fp-ts/lib/function'; -import { QueryContainer } from '@elastic/eui/src/components/search_bar/query/ast_to_es_query_dsl'; import { ElasticsearchClient } from '../../../elasticsearch'; import { IndexMapping } from '../../mappings'; import { SavedObjectsRawDoc, SavedObjectsRawDocSource } from '../../serialization'; @@ -440,9 +439,9 @@ export const reindex = ( requireAlias: boolean, /* When reindexing we use a source query to exclude saved objects types which * are no longer used. These saved objects will still be kept in the outdated - * index for backup purposes, but won't be availble in the upgraded index. + * index for backup purposes, but won't be available in the upgraded index. */ - unusedTypesToExclude: Option.Option + unusedTypesQuery: Option.Option ): TaskEither.TaskEither => () => { return client .reindex({ @@ -457,14 +456,10 @@ export const reindex = ( // Set reindex batch size size: BATCH_SIZE, // Exclude saved object types - query: Option.fold( + query: Option.fold( () => undefined, - (types) => ({ - bool: { - must_not: types.map((type) => ({ term: { type } })), - }, - }) - )(unusedTypesToExclude), + (query) => query + )(unusedTypesQuery), }, dest: { index: targetIndex, diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts index 21c05d22b05819..3905044f04e2fc 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts @@ -416,14 +416,20 @@ describe('migration actions', () => { ] `); }); - it('resolves right and excludes all unusedTypesToExclude documents', async () => { + it('resolves right and excludes all documents not matching the unusedTypesQuery', async () => { const res = (await reindex( client, 'existing_index_with_docs', 'reindex_target_excluded_docs', Option.none, false, - Option.some(['f_agent_event', 'another_unused_type']) + Option.of({ + bool: { + must_not: ['f_agent_event', 'another_unused_type'].map((type) => ({ + term: { type }, + })), + }, + }) )()) as Either.Right; const task = waitForReindexTask(client, res.right.taskId, '10s'); await expect(task()).resolves.toMatchInlineSnapshot(` diff --git a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts index 4d93abcc4018fe..fa2e65f16bb2d2 100644 --- a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts +++ b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts @@ -254,12 +254,40 @@ describe('migrationsStateActionMachine', () => { }, }, }, - "unusedTypesToExclude": Object { + "unusedTypesQuery": Object { "_tag": "Some", - "value": Array [ - "fleet-agent-events", - "tsvb-validation-telemetry", - ], + "value": Object { + "bool": Object { + "must_not": Array [ + Object { + "term": Object { + "type": "fleet-agent-events", + }, + }, + Object { + "term": Object { + "type": "tsvb-validation-telemetry", + }, + }, + Object { + "bool": Object { + "must": Array [ + Object { + "match": Object { + "type": "search-session", + }, + }, + Object { + "match": Object { + "search-session.persisted": false, + }, + }, + ], + }, + }, + ], + }, + }, }, "versionAlias": ".my-so-index_7.11.0", "versionIndex": ".my-so-index_7.11.0_001", @@ -322,12 +350,40 @@ describe('migrationsStateActionMachine', () => { }, }, }, - "unusedTypesToExclude": Object { + "unusedTypesQuery": Object { "_tag": "Some", - "value": Array [ - "fleet-agent-events", - "tsvb-validation-telemetry", - ], + "value": Object { + "bool": Object { + "must_not": Array [ + Object { + "term": Object { + "type": "fleet-agent-events", + }, + }, + Object { + "term": Object { + "type": "tsvb-validation-telemetry", + }, + }, + Object { + "bool": Object { + "must": Array [ + Object { + "match": Object { + "type": "search-session", + }, + }, + Object { + "match": Object { + "search-session.persisted": false, + }, + }, + ], + }, + }, + ], + }, + }, }, "versionAlias": ".my-so-index_7.11.0", "versionIndex": ".my-so-index_7.11.0_001", @@ -475,12 +531,40 @@ describe('migrationsStateActionMachine', () => { }, }, }, - "unusedTypesToExclude": Object { + "unusedTypesQuery": Object { "_tag": "Some", - "value": Array [ - "fleet-agent-events", - "tsvb-validation-telemetry", - ], + "value": Object { + "bool": Object { + "must_not": Array [ + Object { + "term": Object { + "type": "fleet-agent-events", + }, + }, + Object { + "term": Object { + "type": "tsvb-validation-telemetry", + }, + }, + Object { + "bool": Object { + "must": Array [ + Object { + "match": Object { + "type": "search-session", + }, + }, + Object { + "match": Object { + "search-session.persisted": false, + }, + }, + ], + }, + }, + ], + }, + }, }, "versionAlias": ".my-so-index_7.11.0", "versionIndex": ".my-so-index_7.11.0_001", @@ -538,12 +622,40 @@ describe('migrationsStateActionMachine', () => { }, }, }, - "unusedTypesToExclude": Object { + "unusedTypesQuery": Object { "_tag": "Some", - "value": Array [ - "fleet-agent-events", - "tsvb-validation-telemetry", - ], + "value": Object { + "bool": Object { + "must_not": Array [ + Object { + "term": Object { + "type": "fleet-agent-events", + }, + }, + Object { + "term": Object { + "type": "tsvb-validation-telemetry", + }, + }, + Object { + "bool": Object { + "must": Array [ + Object { + "match": Object { + "type": "search-session", + }, + }, + Object { + "match": Object { + "search-session.persisted": false, + }, + }, + ], + }, + }, + ], + }, + }, }, "versionAlias": ".my-so-index_7.11.0", "versionIndex": ".my-so-index_7.11.0_001", diff --git a/src/core/server/saved_objects/migrationsv2/model.test.ts b/src/core/server/saved_objects/migrationsv2/model.test.ts index 8aad62f13b8fea..0267ae33dd157c 100644 --- a/src/core/server/saved_objects/migrationsv2/model.test.ts +++ b/src/core/server/saved_objects/migrationsv2/model.test.ts @@ -70,7 +70,17 @@ describe('migrations v2 model', () => { versionAlias: '.kibana_7.11.0', versionIndex: '.kibana_7.11.0_001', tempIndex: '.kibana_7.11.0_reindex_temp', - unusedTypesToExclude: Option.some(['unused-fleet-agent-events']), + unusedTypesQuery: Option.of({ + bool: { + must_not: [ + { + term: { + type: 'unused-fleet-agent-events', + }, + }, + ], + }, + }), }; describe('exponential retry delays for retryable_es_client_error', () => { @@ -1177,12 +1187,40 @@ describe('migrations v2 model', () => { }, }, }, - "unusedTypesToExclude": Object { + "unusedTypesQuery": Object { "_tag": "Some", - "value": Array [ - "fleet-agent-events", - "tsvb-validation-telemetry", - ], + "value": Object { + "bool": Object { + "must_not": Array [ + Object { + "term": Object { + "type": "fleet-agent-events", + }, + }, + Object { + "term": Object { + "type": "tsvb-validation-telemetry", + }, + }, + Object { + "bool": Object { + "must": Array [ + Object { + "match": Object { + "type": "search-session", + }, + }, + Object { + "match": Object { + "search-session.persisted": false, + }, + }, + ], + }, + }, + ], + }, + }, }, "versionAlias": ".kibana_task_manager_8.1.0", "versionIndex": ".kibana_task_manager_8.1.0_001", diff --git a/src/core/server/saved_objects/migrationsv2/model.ts b/src/core/server/saved_objects/migrationsv2/model.ts index ee78692a7044f0..acf0f620136a2c 100644 --- a/src/core/server/saved_objects/migrationsv2/model.ts +++ b/src/core/server/saved_objects/migrationsv2/model.ts @@ -16,6 +16,7 @@ import { IndexMapping } from '../mappings'; import { ResponseType } from './next'; import { SavedObjectsMigrationVersion } from '../types'; import { disableUnknownTypeMappingFields } from '../migrations/core/migration_context'; +import { excludeUnusedTypesQuery } from '../migrations/core'; import { SavedObjectsMigrationConfigType } from '../saved_objects_config'; /** @@ -74,6 +75,7 @@ function indexBelongsToLaterVersion(indexName: string, kibanaVersion: string): b const version = valid(indexVersion(indexName)); return version != null ? gt(version, kibanaVersion) : false; } + /** * Extracts the version number from a >= 7.11 index * @param indexName A >= v7.11 index name @@ -781,11 +783,6 @@ export const createInitialState = ({ }, }; - const unusedTypesToExclude = Option.some([ - 'fleet-agent-events', // https://github.com/elastic/kibana/issues/91869 - 'tsvb-validation-telemetry', // https://github.com/elastic/kibana/issues/95617 - ]); - const initialState: InitState = { controlState: 'INIT', indexPrefix, @@ -804,7 +801,7 @@ export const createInitialState = ({ retryAttempts: migrationsConfig.retryAttempts, batchSize: migrationsConfig.batchSize, logs: [], - unusedTypesToExclude, + unusedTypesQuery: Option.of(excludeUnusedTypesQuery), }; return initialState; }; diff --git a/src/core/server/saved_objects/migrationsv2/next.ts b/src/core/server/saved_objects/migrationsv2/next.ts index 5cbda741a0ce5a..bb506cbca66fb1 100644 --- a/src/core/server/saved_objects/migrationsv2/next.ts +++ b/src/core/server/saved_objects/migrationsv2/next.ts @@ -70,7 +70,7 @@ export const nextActionMap = (client: ElasticsearchClient, transformRawDocs: Tra state.tempIndex, Option.none, false, - state.unusedTypesToExclude + state.unusedTypesQuery ), SET_TEMP_WRITE_BLOCK: (state: SetTempWriteBlock) => Actions.setWriteBlock(client, state.tempIndex), @@ -115,7 +115,7 @@ export const nextActionMap = (client: ElasticsearchClient, transformRawDocs: Tra state.sourceIndex.value, state.preMigrationScript, false, - state.unusedTypesToExclude + state.unusedTypesQuery ), LEGACY_REINDEX_WAIT_FOR_TASK: (state: LegacyReindexWaitForTaskState) => Actions.waitForReindexTask(client, state.legacyReindexTaskId, '60s'), diff --git a/src/core/server/saved_objects/migrationsv2/types.ts b/src/core/server/saved_objects/migrationsv2/types.ts index e9b351c0152fc0..5e84bc23b1d161 100644 --- a/src/core/server/saved_objects/migrationsv2/types.ts +++ b/src/core/server/saved_objects/migrationsv2/types.ts @@ -7,6 +7,7 @@ */ import * as Option from 'fp-ts/lib/Option'; +import { estypes } from '@elastic/elasticsearch'; import { ControlState } from './state_action_machine'; import { AliasAction } from './actions'; import { IndexMapping } from '../mappings'; @@ -91,9 +92,9 @@ export interface BaseState extends ControlState { readonly tempIndex: string; /* When reindexing we use a source query to exclude saved objects types which * are no longer used. These saved objects will still be kept in the outdated - * index for backup purposes, but won't be availble in the upgraded index. + * index for backup purposes, but won't be available in the upgraded index. */ - readonly unusedTypesToExclude: Option.Option; + readonly unusedTypesQuery: Option.Option; } export type InitState = BaseState & { From 451c5a6fae1f352702371e91a71638d6431e88aa Mon Sep 17 00:00:00 2001 From: Thomas Neirynck Date: Tue, 13 Apr 2021 09:20:11 -0400 Subject: [PATCH 20/90] [Maps] Enable filtering with spatial relationships on geo_point fields (#96849) --- .../elasticsearch_geo_utils.ts | 17 +--- .../geometry_filter_form.test.js.snap | 92 +++++++++++++++---- .../public/components/geometry_filter_form.js | 22 ++--- .../components/geometry_filter_form.test.js | 2 +- .../draw_filter_control.tsx | 9 +- .../feature_geometry_filter_form.js | 9 +- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 8 files changed, 89 insertions(+), 64 deletions(-) diff --git a/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.ts b/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.ts index f2a8b95f7b643d..197b7f49eda0af 100644 --- a/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.ts +++ b/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.ts @@ -369,7 +369,6 @@ export function createSpatialFilterWithGeometry({ geometryLabel, indexPatternId, geoFieldName, - geoFieldType, relation = ES_SPATIAL_RELATIONS.INTERSECTS, }: { preIndexedShape?: PreIndexedShape; @@ -377,32 +376,20 @@ export function createSpatialFilterWithGeometry({ geometryLabel: string; indexPatternId: string; geoFieldName: string; - geoFieldType: ES_GEO_FIELD_TYPE; relation: ES_SPATIAL_RELATIONS; }): GeoFilter { - ensureGeoField(geoFieldType); - - const isGeoPoint = geoFieldType === ES_GEO_FIELD_TYPE.GEO_POINT; - - const relationLabel = isGeoPoint - ? i18n.translate('xpack.maps.es_geo_utils.shapeFilter.geoPointRelationLabel', { - defaultMessage: 'in', - }) - : getEsSpatialRelationLabel(relation); const meta: FilterMeta = { type: SPATIAL_FILTER_TYPE, negate: false, index: indexPatternId, key: geoFieldName, - alias: `${geoFieldName} ${relationLabel} ${geometryLabel}`, + alias: `${geoFieldName} ${getEsSpatialRelationLabel(relation)} ${geometryLabel}`, disabled: false, }; const shapeQuery: GeoShapeQueryBody = { - // geo_shape query with geo_point field only supports intersects relation - relation: isGeoPoint ? ES_SPATIAL_RELATIONS.INTERSECTS : relation, + relation, }; - if (preIndexedShape) { shapeQuery.indexed_shape = preIndexedShape; } else { diff --git a/x-pack/plugins/maps/public/components/__snapshots__/geometry_filter_form.test.js.snap b/x-pack/plugins/maps/public/components/__snapshots__/geometry_filter_form.test.js.snap index 2d39a52dfe9745..ccbe4667b78ea4 100644 --- a/x-pack/plugins/maps/public/components/__snapshots__/geometry_filter_form.test.js.snap +++ b/x-pack/plugins/maps/public/components/__snapshots__/geometry_filter_form.test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`should not render relation select when geo field is geo_point 1`] = ` +exports[`should not show "within" relation when filter geometry is not closed 1`] = ` + + + `; -exports[`should not show "within" relation when filter geometry is not closed 1`] = ` +exports[`should render error message 1`] = ` + + Simulated error + @@ -147,7 +177,7 @@ exports[`should not show "within" relation when filter geometry is not closed 1` `; -exports[`should render error message 1`] = ` +exports[`should render relation select when geo field is geo_shape 1`] = ` + + + - - Simulated error - @@ -210,7 +268,7 @@ exports[`should render error message 1`] = ` `; -exports[`should render relation select when geo field is geo_shape 1`] = ` +exports[`should render relation select without "within"-relation when geo field is geo_point 1`] = ` { - // can not filter by within relation when filtering geometry is not closed - return relation !== ES_SPATIAL_RELATIONS.WITHIN; - }); + const spatialRelations = + this.props.isFilterGeometryClosed && + this.state.selectedField.geoFieldType !== ES_GEO_FIELD_TYPE.GEO_POINT + ? Object.values(ES_SPATIAL_RELATIONS) + : Object.values(ES_SPATIAL_RELATIONS).filter((relation) => { + // - cannot filter by "within"-relation when filtering geometry is not closed + // - do not distinguish between intersects/within for filtering for points since they are equivalent + return relation !== ES_SPATIAL_RELATIONS.WITHIN; + }); + const options = spatialRelations.map((relation) => { return { value: relation, diff --git a/x-pack/plugins/maps/public/components/geometry_filter_form.test.js b/x-pack/plugins/maps/public/components/geometry_filter_form.test.js index f1876198f8b676..d981caf944ab97 100644 --- a/x-pack/plugins/maps/public/components/geometry_filter_form.test.js +++ b/x-pack/plugins/maps/public/components/geometry_filter_form.test.js @@ -16,7 +16,7 @@ const defaultProps = { onSubmit: () => {}, }; -test('should not render relation select when geo field is geo_point', async () => { +test('should render relation select without "within"-relation when geo field is geo_point', async () => { const component = shallow( { : geometry, indexPatternId: this.props.drawState.indexPatternId, geoFieldName: this.props.drawState.geoFieldName, - geoFieldType: this.props.drawState.geoFieldType - ? this.props.drawState.geoFieldType - : ES_GEO_FIELD_TYPE.GEO_POINT, geometryLabel: this.props.drawState.geometryLabel ? this.props.drawState.geometryLabel : '', relation: this.props.drawState.relation ? this.props.drawState.relation diff --git a/x-pack/plugins/maps/public/connected_components/mb_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 index 3950c6ef124bee..9d4cf78c98754e 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_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 @@ -52,13 +52,7 @@ export class FeatureGeometryFilterForm extends Component { return preIndexedShape; }; - _createFilter = async ({ - geometryLabel, - indexPatternId, - geoFieldName, - geoFieldType, - relation, - }) => { + _createFilter = async ({ geometryLabel, indexPatternId, geoFieldName, relation }) => { this.setState({ errorMsg: undefined }); const preIndexedShape = await this._loadPreIndexedShape(); if (!this._isMounted) { @@ -72,7 +66,6 @@ export class FeatureGeometryFilterForm extends Component { geometryLabel, indexPatternId, geoFieldName, - geoFieldType, relation, }); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 8f71353113f5f0..a0f535e93a8a63 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -12424,7 +12424,6 @@ "xpack.maps.es_geo_utils.convert.invalidGeometryCollectionErrorMessage": "GeometryCollectionを convertESShapeToGeojsonGeometryに渡さないでください", "xpack.maps.es_geo_utils.convert.unsupportedGeometryTypeErrorMessage": "{geometryType} ジオメトリから Geojson に変換できません。サポートされていません", "xpack.maps.es_geo_utils.distanceFilterAlias": "{pointLabel} の {distanceKm}km 以内にある {geoFieldName}", - "xpack.maps.es_geo_utils.shapeFilter.geoPointRelationLabel": "in", "xpack.maps.es_geo_utils.unsupportedFieldTypeErrorMessage": "サポートされていないフィールドタイプ、期待値:{expectedTypes}、提供された値:{fieldType}", "xpack.maps.es_geo_utils.unsupportedGeometryTypeErrorMessage": "サポートされていないジオメトリタイプ、期待値:{expectedTypes}、提供された値:{geometryType}", "xpack.maps.es_geo_utils.wkt.invalidWKTErrorMessage": "{wkt} を Geojson に変換できません。有効な WKT が必要です。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 7269615c051db8..31bc197f2ea059 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -12591,7 +12591,6 @@ "xpack.maps.es_geo_utils.convert.invalidGeometryCollectionErrorMessage": "不应将 GeometryCollection 传递给 convertESShapeToGeojsonGeometry", "xpack.maps.es_geo_utils.convert.unsupportedGeometryTypeErrorMessage": "无法将 {geometryType} 几何图形转换成 geojson,不支持", "xpack.maps.es_geo_utils.distanceFilterAlias": "{pointLabel} {distanceKm}km 内的 {geoFieldName}", - "xpack.maps.es_geo_utils.shapeFilter.geoPointRelationLabel": "于", "xpack.maps.es_geo_utils.unsupportedFieldTypeErrorMessage": "字段类型不受支持,应为 {expectedTypes},而提供的是 {fieldType}", "xpack.maps.es_geo_utils.unsupportedGeometryTypeErrorMessage": "几何类型不受支持,应为 {expectedTypes},而提供的是 {geometryType}", "xpack.maps.es_geo_utils.wkt.invalidWKTErrorMessage": "无法将 {wkt} 转换成 geojson。需要有效的 WKT。", From 25000b40911de78dbfeeee2fe92b381ae05d4e43 Mon Sep 17 00:00:00 2001 From: Thomas Neirynck Date: Tue, 13 Apr 2021 09:21:21 -0400 Subject: [PATCH 21/90] [Maps] wrap flaky test in retry block (#96448) --- x-pack/test/functional/apps/maps/embeddable/dashboard.js | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/test/functional/apps/maps/embeddable/dashboard.js b/x-pack/test/functional/apps/maps/embeddable/dashboard.js index e1181119bee09a..860273bc23cc10 100644 --- a/x-pack/test/functional/apps/maps/embeddable/dashboard.js +++ b/x-pack/test/functional/apps/maps/embeddable/dashboard.js @@ -35,6 +35,7 @@ export default function ({ getPageObjects, getService }) { }); await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.loadSavedDashboard('map embeddable example'); + await PageObjects.dashboard.waitForRenderComplete(); }); after(async () => { From bc59d55d6759744cecd327a8a5551358a05153a7 Mon Sep 17 00:00:00 2001 From: Dmitry Tomashevich <39378793+Dmitriynj@users.noreply.github.com> Date: Tue, 13 Apr 2021 16:26:49 +0300 Subject: [PATCH 22/90] [TSVB] Fix annotation line doesn't work if no index pattern is applied (#96646) * [TSVB] fix annotation line doesnt work if no index pattern is applied * [TSVB] remove series from annotations, remove timeField placeholder Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../server/lib/vis_data/get_annotations.ts | 9 +-------- .../request_processors/annotations/date_histogram.js | 2 +- .../lib/vis_data/request_processors/annotations/query.js | 2 +- .../vis_data/request_processors/annotations/top_hits.js | 2 +- 4 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_annotations.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_annotations.ts index 6c19163a5ee20c..1e2f6f39d00cfa 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_annotations.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_annotations.ts @@ -19,14 +19,7 @@ import { getLastSeriesTimestamp } from './helpers/timestamp'; import { VisTypeTimeseriesVisDataRequest } from '../../types'; function validAnnotation(annotation: AnnotationItemsSchema) { - return ( - annotation.index_pattern && - annotation.time_field && - annotation.fields && - annotation.icon && - annotation.template && - !annotation.hidden - ); + return annotation.fields && annotation.icon && annotation.template && !annotation.hidden; } interface GetAnnotationsParams { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js index f3ee416be81a86..48b35d0db50861 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js @@ -25,7 +25,7 @@ export function dateHistogram( ) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const timeField = annotation.time_field; + const timeField = annotation.time_field || annotationIndex.indexPattern?.timeFieldName || ''; validateField(timeField, annotationIndex); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/query.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/query.js index 46a3c369e548d3..3be567dfe1f406 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/query.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/query.js @@ -22,7 +22,7 @@ export function query( ) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const timeField = (annotation.time_field || annotationIndex.indexPattern?.timeField) ?? ''; + const timeField = (annotation.time_field || annotationIndex.indexPattern?.timeFieldName) ?? ''; validateField(timeField, annotationIndex); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/top_hits.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/top_hits.js index 1b4434c4867c82..447cfdbc8c6e4d 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/top_hits.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/top_hits.js @@ -12,7 +12,7 @@ import { validateField } from '../../../../../common/fields_utils'; export function topHits(req, panel, annotation, esQueryConfig, annotationIndex) { return (next) => (doc) => { const fields = (annotation.fields && annotation.fields.split(/[,\s]+/)) || []; - const timeField = annotation.time_field; + const timeField = annotation.time_field || annotationIndex.indexPattern?.timeFieldName || ''; validateField(timeField, annotationIndex); From 93e270e60ad165dd6e986c24fafb096498ead369 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Tue, 13 Apr 2021 08:31:00 -0500 Subject: [PATCH 23/90] [Enterprise Search] Design Pass: Role mappings (#96882) * Update shared button color and panel shading * Vertically align table cells to top * [App Search] Update panels to have backgrounds not borders * [Workplace Search] Update panels to have backgrounds not borders * re-align last cell to right Accidentally deleted it refactoring * Conditionally have border for App Search Requested to remove for empty state --- .../components/role_mappings/role_mapping.tsx | 4 ++-- .../components/role_mappings/role_mappings.tsx | 17 ++++++++++------- .../role_mapping/add_role_mapping_button.tsx | 2 +- .../shared/role_mapping/attribute_selector.tsx | 2 +- .../role_mapping/role_mappings_table.scss | 12 ++++++++++++ .../shared/role_mapping/role_mappings_table.tsx | 6 ++++-- .../views/role_mappings/role_mapping.tsx | 4 ++-- .../views/role_mappings/role_mappings.tsx | 16 +++++++++------- 8 files changed, 41 insertions(+), 22 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.scss diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx index ebd034caaedb39..47c0eb2483ec12 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx @@ -166,7 +166,7 @@ export const RoleMapping: React.FC = ({ isNew }) => { - +

{ROLE_TITLE}

@@ -189,7 +189,7 @@ export const RoleMapping: React.FC = ({ isNew }) => {
{hasAdvancedRoles && ( - +

{ENGINE_ACCESS_TITLE}

diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx index 2ec2b93d1e24f3..e8d9e06142ef82 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx @@ -17,6 +17,7 @@ import { EuiPageContent, EuiPageContentBody, EuiPageHeader, + EuiPanel, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -78,12 +79,14 @@ export const RoleMappings: React.FC = () => { const addMappingButton = ; const roleMappingEmptyState = ( - {EMPTY_ROLE_MAPPINGS_TITLE}} - body={

{EMPTY_ROLE_MAPPINGS_BODY}

} - actions={addMappingButton} - /> + + {EMPTY_ROLE_MAPPINGS_TITLE}} + body={

{EMPTY_ROLE_MAPPINGS_BODY}

} + actions={addMappingButton} + /> +
); const roleMappingsTable = ( @@ -127,7 +130,7 @@ export const RoleMappings: React.FC = () => { pageTitle={ROLE_MAPPINGS_TITLE} description={ROLE_MAPPINGS_DESCRIPTION} /> - + 0}> {roleMappings.length === 0 ? roleMappingEmptyState : roleMappingsTable} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.tsx index 0ae9f16ea2f9be..097302e0aa5f12 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/add_role_mapping_button.tsx @@ -16,7 +16,7 @@ interface Props { } export const AddRoleMappingButton: React.FC = ({ path }) => ( - + {ADD_ROLE_MAPPING_BUTTON} ); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.tsx index 0417331be208d6..0ee093ed934c9b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/attribute_selector.tsx @@ -100,7 +100,7 @@ export const AttributeSelector: React.FC = ({ handleAuthProviderChange = () => null, }) => { return ( - +

{ATTRIBUTE_SELECTOR_TITLE}

diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.scss b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.scss new file mode 100644 index 00000000000000..6eaa3b92579367 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.scss @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +.roleMappingsTable { + td { + vertical-align: top; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx index 6db62e4c10b6bb..a5f6fb368c96f1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx @@ -29,6 +29,8 @@ import { MANAGE_BUTTON_LABEL } from '../constants'; import { EuiLinkTo } from '../react_router_helpers'; import { RoleRules } from '../types'; +import './role_mappings_table.scss'; + import { ANY_AUTH_PROVIDER, ANY_AUTH_PROVIDER_OPTION_LABEL, @@ -108,7 +110,7 @@ export const RoleMappingsTable: React.FC = ({
{filteredResults.length > 0 ? ( - + {EXTERNAL_ATTRIBUTE_LABEL} {ATTRIBUTE_VALUE_LABEL} @@ -152,7 +154,7 @@ export const RoleMappingsTable: React.FC = ({ {authProvider.map(getAuthProviderDisplayValue).join(', ')} )} - + {id && {MANAGE_BUTTON_LABEL}} {toolTip && } diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.tsx index 7db1e82d29449c..d69e94b20444ea 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.tsx @@ -141,7 +141,7 @@ export const RoleMapping: React.FC = ({ isNew }) => { - +

{ROLE_LABEL}

@@ -158,7 +158,7 @@ export const RoleMapping: React.FC = ({ isNew }) => {
- +

{GROUP_ASSIGNMENT_TITLE}

diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx index 842c59e683f06e..0e3533d48a5a97 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx @@ -9,7 +9,7 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; -import { EuiEmptyPrompt } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiPanel } from '@elastic/eui'; import { FlashMessages } from '../../../shared/flash_messages'; import { Loading } from '../../../shared/loading'; @@ -39,12 +39,14 @@ export const RoleMappings: React.FC = () => { const addMappingButton = ; const emptyPrompt = ( - {EMPTY_ROLE_MAPPINGS_TITLE}} - body={

{EMPTY_ROLE_MAPPINGS_BODY}

} - actions={addMappingButton} - /> + + {EMPTY_ROLE_MAPPINGS_TITLE}} + body={

{EMPTY_ROLE_MAPPINGS_BODY}

} + actions={addMappingButton} + /> +
); const roleMappingsTable = ( Date: Tue, 13 Apr 2021 09:31:18 -0400 Subject: [PATCH 24/90] [Telemetry] Fix Logstash telemetry collection for multi node clusters (#96831) Prior to this fix, each Logstash node was overwriting the collected list of ephemeral ids used to collect pipeline details. This meant that pipeline details were only being collected for the last Logstash node retrieved for each cluster. --- .../get_logstash_stats.test.ts | 140 ++++++++++++++++++ .../get_logstash_stats.ts | 6 +- 2 files changed, 142 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_logstash_stats.test.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_logstash_stats.test.ts index f2f0c37255d924..cf1574f8d3f0ee 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_logstash_stats.test.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_logstash_stats.test.ts @@ -194,6 +194,117 @@ describe('Get Logstash Stats', () => { }); }); + it('should retrieve all ephemeral ids from all hits for the same cluster', () => { + const results = { + hits: { + hits: [ + { + _source: { + type: 'logstash_stats', + cluster_uuid: 'FlV4ckTxQ0a78hmBkzzc9A', + logstash_stats: { + logstash: { + uuid: '0000000-0000-0000-0000-000000000000', + }, + pipelines: [ + { + id: 'main', + ephemeral_id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + queue: { + type: 'memory', + }, + }, + ], + }, + }, + }, + { + _source: { + type: 'logstash_stats', + cluster_uuid: 'FlV4ckTxQ0a78hmBkzzc9A', + logstash_stats: { + logstash: { + uuid: '11111111-1111-1111-1111-111111111111', + }, + pipelines: [ + { + id: 'main', + ephemeral_id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + queue: { + type: 'memory', + }, + }, + ], + }, + }, + }, + { + _source: { + type: 'logstash_stats', + cluster_uuid: '3', + logstash_stats: { + logstash: { + uuid: '22222222-2222-2222-2222-222222222222', + }, + pipelines: [ + { + id: 'main', + ephemeral_id: 'cccccccc-cccc-cccc-cccc-cccccccccccc', + queue: { + type: 'memory', + }, + }, + ], + }, + }, + }, + ], + }, + }; + + const options = getBaseOptions(); + processStatsResults(results as any, options); + + expect(options.allEphemeralIds).toStrictEqual({ + FlV4ckTxQ0a78hmBkzzc9A: [ + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + ], + '3': ['cccccccc-cccc-cccc-cccc-cccccccccccc'], + }); + + expect(options.clusters).toStrictEqual({ + FlV4ckTxQ0a78hmBkzzc9A: { + count: 2, + cluster_stats: { + plugins: [], + collection_types: { + internal_collection: 2, + }, + pipelines: {}, + queues: { + memory: 2, + }, + }, + versions: [], + }, + '3': { + count: 1, + cluster_stats: { + plugins: [], + collection_types: { + internal_collection: 1, + }, + pipelines: {}, + queues: { + memory: 1, + }, + }, + versions: [], + }, + }); + }); + it('should summarize stats from hits across multiple result objects', () => { const options = getBaseOptions(); @@ -208,6 +319,35 @@ describe('Get Logstash Stats', () => { }); }); + expect(options.allEphemeralIds).toStrictEqual({ + '1n1p': ['cf37c6fa-2f1a-41e2-9a89-36b420a8b9a5'], + '1nmp': [ + '47a70feb-3cb5-4618-8670-2c0bada61acd', + '5a65d966-0330-4bd7-82f2-ee81040c13cf', + '8d33fe25-a2c0-4c54-9ecf-d218cb8dbfe4', + 'f4167a94-20a8-43e7-828e-4cf38d906187', + ], + mnmp: [ + '2fcd4161-e08f-4eea-818b-703ea3ec6389', + 'c6785d63-6e5f-42c2-839d-5edf139b7c19', + 'bc6ef6f2-ecce-4328-96a2-002de41a144d', + '72058ad1-68a1-45f6-a8e8-10621ffc7288', + '18593052-c021-4158-860d-d8122981a0ac', + '4207025c-9b00-4bea-a36c-6fbf2d3c215e', + '0ec4702d-b5e5-4c60-91e9-6fa6a836f0d1', + '41258219-b129-4fad-a629-f244826281f8', + 'e73bc63d-561a-4acd-a0c4-d5f70c4603df', + 'ddf882b7-be26-4a93-8144-0aeb35122651', + '602936f5-98a3-4f8c-9471-cf389a519f4b', + '8b300988-62cc-4bc6-9ee0-9194f3f78e27', + '6ab60531-fb6f-478c-9063-82f2b0af2bed', + '802a5994-a03c-44b8-a650-47c0f71c2e48', + '6070b400-5c10-4c5e-b5c5-a5bd9be6d321', + '3193df5f-2a34-4fe3-816e-6b05999aa5ce', + '994e68cd-d607-40e6-a54c-02a51caa17e0', + ], + }); + expect(options.clusters).toStrictEqual({ '1n1p': { count: 1, diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_logstash_stats.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_logstash_stats.ts index 93c69c644c0649..f4f67a5582303d 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_logstash_stats.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_logstash_stats.ts @@ -147,8 +147,6 @@ export function processStatsResults( } clusterStats.collection_types![thisCollectionType] = (clusterStats.collection_types![thisCollectionType] || 0) + 1; - - const theseEphemeralIds: string[] = []; const pipelines = logstashStats.pipelines || []; pipelines.forEach((pipeline) => { @@ -162,10 +160,10 @@ export function processStatsResults( const ephemeralId = pipeline.ephemeral_id; if (ephemeralId !== undefined) { - theseEphemeralIds.push(ephemeralId); + allEphemeralIds[clusterUuid] = allEphemeralIds[clusterUuid] || []; + allEphemeralIds[clusterUuid].push(ephemeralId); } }); - allEphemeralIds[clusterUuid] = theseEphemeralIds; } }); } From 73ccf7844a64a4395b3059d4c98ca46026fca826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20Kopyci=C5=84ski?= Date: Tue, 13 Apr 2021 15:38:12 +0200 Subject: [PATCH 25/90] [Fleet] Add support for long and double field type in multi_fields (#96834) --- .../elasticsearch/template/template.test.ts | 58 +++++++++++++++++++ .../epm/elasticsearch/template/template.ts | 6 ++ 2 files changed, 64 insertions(+) diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts index df82aa90b5a131..dcc685bb270b46 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts @@ -301,6 +301,64 @@ describe('EPM template', () => { expect(mappings).toEqual(keywordWithNormalizedMultiFieldsMapping); }); + it('tests processing keyword field with multi fields with long field', () => { + const keywordWithMultiFieldsLiteralYml = ` + - name: keywordWithMultiFields + type: keyword + multi_fields: + - name: number_memory_devices + type: long + normalizer: lowercase + `; + + const keywordWithMultiFieldsMapping = { + properties: { + keywordWithMultiFields: { + ignore_above: 1024, + type: 'keyword', + fields: { + number_memory_devices: { + type: 'long', + }, + }, + }, + }, + }; + const fields: Field[] = safeLoad(keywordWithMultiFieldsLiteralYml); + const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); + expect(mappings).toEqual(keywordWithMultiFieldsMapping); + }); + + it('tests processing keyword field with multi fields with double field', () => { + const keywordWithMultiFieldsLiteralYml = ` + - name: keywordWithMultiFields + type: keyword + multi_fields: + - name: number + type: double + normalizer: lowercase + `; + + const keywordWithMultiFieldsMapping = { + properties: { + keywordWithMultiFields: { + ignore_above: 1024, + type: 'keyword', + fields: { + number: { + type: 'double', + }, + }, + }, + }, + }; + const fields: Field[] = safeLoad(keywordWithMultiFieldsLiteralYml); + const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); + expect(mappings).toEqual(keywordWithMultiFieldsMapping); + }); + it('tests processing object field with no other attributes', () => { const objectFieldLiteralYml = ` - name: objectField diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index 0b95f8d76627a9..f6ca1dfc99f4e0 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts @@ -204,6 +204,12 @@ function generateMultiFields(fields: Fields): MultiFields { case 'keyword': multiFields[f.name] = { ...generateKeywordMapping(f), type: f.type }; break; + case 'long': + multiFields[f.name] = { type: f.type }; + break; + case 'double': + multiFields[f.name] = { type: f.type }; + break; } }); } From 8cce4805d4bf3d593c7c467f56572121f990718b Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm Date: Tue, 13 Apr 2021 15:54:42 +0200 Subject: [PATCH 26/90] [Discover][EuiDataGrid] Add document selector (#94804) Co-authored-by: Ryan Keairns --- .../discover_grid/discover_grid.test.tsx | 146 +++++++++++++++ .../discover_grid/discover_grid.tsx | 54 +++++- .../discover_grid_cell_actions.test.tsx | 4 + .../discover_grid/discover_grid_columns.tsx | 15 ++ .../discover_grid/discover_grid_context.tsx | 2 + .../discover_grid_document_selection.test.tsx | 143 +++++++++++++++ .../discover_grid_document_selection.tsx | 170 ++++++++++++++++++ .../discover_grid_expand_button.test.tsx | 6 + .../apps/dashboard/embeddable_data_grid.ts | 4 +- .../apps/discover/_data_grid_field_data.ts | 2 +- test/functional/services/data_grid.ts | 2 +- 11 files changed, 537 insertions(+), 11 deletions(-) create mode 100644 src/plugins/discover/public/application/components/discover_grid/discover_grid.test.tsx create mode 100644 src/plugins/discover/public/application/components/discover_grid/discover_grid_document_selection.test.tsx create mode 100644 src/plugins/discover/public/application/components/discover_grid/discover_grid_document_selection.tsx diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid.test.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid.test.tsx new file mode 100644 index 00000000000000..8037022085f024 --- /dev/null +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid.test.tsx @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { ReactWrapper } from 'enzyme'; +import { EuiCopy } from '@elastic/eui'; +import { act } from 'react-dom/test-utils'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { esHits } from '../../../__mocks__/es_hits'; +import { indexPatternMock } from '../../../__mocks__/index_pattern'; +import { mountWithIntl } from '@kbn/test/jest'; +import { DiscoverGrid, DiscoverGridProps } from './discover_grid'; +import { uiSettingsMock } from '../../../__mocks__/ui_settings'; +import { DiscoverServices } from '../../../build_services'; +import { ElasticSearchHit } from '../../doc_views/doc_views_types'; +import { getDocId } from './discover_grid_document_selection'; + +function getProps() { + const servicesMock = { + uiSettings: uiSettingsMock, + } as DiscoverServices; + return { + ariaLabelledBy: '', + columns: [], + indexPattern: indexPatternMock, + isLoading: false, + expandedDoc: undefined, + onAddColumn: jest.fn(), + onFilter: jest.fn(), + onRemoveColumn: jest.fn(), + onResize: jest.fn(), + onSetColumns: jest.fn(), + onSort: jest.fn(), + rows: esHits, + sampleSize: 30, + searchDescription: '', + searchTitle: '', + services: servicesMock, + setExpandedDoc: jest.fn(), + settings: {}, + showTimeCol: true, + sort: [], + useNewFieldsApi: true, + }; +} + +function getComponent() { + return mountWithIntl(); +} + +function getSelectedDocNr(component: ReactWrapper) { + const gridSelectionBtn = findTestSubject(component, 'dscGridSelectionBtn'); + if (!gridSelectionBtn.length) { + return 0; + } + const selectedNr = gridSelectionBtn.getDOMNode().getAttribute('data-selected-documents'); + return Number(selectedNr); +} + +function getDisplayedDocNr(component: ReactWrapper) { + const gridSelectionBtn = findTestSubject(component, 'discoverDocTable'); + if (!gridSelectionBtn.length) { + return 0; + } + const selectedNr = gridSelectionBtn.getDOMNode().getAttribute('data-document-number'); + return Number(selectedNr); +} + +async function toggleDocSelection( + component: ReactWrapper, + document: ElasticSearchHit +) { + act(() => { + const docId = getDocId(document); + findTestSubject(component, `dscGridSelectDoc-${docId}`).simulate('change'); + }); + component.update(); +} + +describe('DiscoverGrid', () => { + describe('Document selection', () => { + let component: ReactWrapper; + beforeEach(() => { + component = getComponent(); + }); + + test('no documents are selected initially', async () => { + expect(getSelectedDocNr(component)).toBe(0); + expect(getDisplayedDocNr(component)).toBe(5); + }); + + test('Allows selection/deselection of multiple documents', async () => { + await toggleDocSelection(component, esHits[0]); + expect(getSelectedDocNr(component)).toBe(1); + await toggleDocSelection(component, esHits[1]); + expect(getSelectedDocNr(component)).toBe(2); + await toggleDocSelection(component, esHits[1]); + expect(getSelectedDocNr(component)).toBe(1); + }); + + test('deselection of all selected documents', async () => { + await toggleDocSelection(component, esHits[0]); + await toggleDocSelection(component, esHits[1]); + expect(getSelectedDocNr(component)).toBe(2); + findTestSubject(component, 'dscGridSelectionBtn').simulate('click'); + findTestSubject(component, 'dscGridClearSelectedDocuments').simulate('click'); + expect(getSelectedDocNr(component)).toBe(0); + }); + + test('showing only selected documents and undo selection', async () => { + await toggleDocSelection(component, esHits[0]); + await toggleDocSelection(component, esHits[1]); + expect(getSelectedDocNr(component)).toBe(2); + findTestSubject(component, 'dscGridSelectionBtn').simulate('click'); + findTestSubject(component, 'dscGridShowSelectedDocuments').simulate('click'); + expect(getDisplayedDocNr(component)).toBe(2); + findTestSubject(component, 'dscGridSelectionBtn').simulate('click'); + component.update(); + findTestSubject(component, 'dscGridShowAllDocuments').simulate('click'); + expect(getDisplayedDocNr(component)).toBe(5); + }); + + test('showing only selected documents and remove filter deselecting each doc manually', async () => { + await toggleDocSelection(component, esHits[0]); + findTestSubject(component, 'dscGridSelectionBtn').simulate('click'); + findTestSubject(component, 'dscGridShowSelectedDocuments').simulate('click'); + expect(getDisplayedDocNr(component)).toBe(1); + await toggleDocSelection(component, esHits[0]); + expect(getDisplayedDocNr(component)).toBe(5); + await toggleDocSelection(component, esHits[0]); + expect(getDisplayedDocNr(component)).toBe(5); + }); + + test('copying selected documents to clipboard', async () => { + await toggleDocSelection(component, esHits[0]); + findTestSubject(component, 'dscGridSelectionBtn').simulate('click'); + expect(component.find(EuiCopy).prop('textToCopy')).toMatchInlineSnapshot( + `"[{\\"_index\\":\\"i\\",\\"_id\\":\\"1\\",\\"_score\\":1,\\"_type\\":\\"_doc\\",\\"_source\\":{\\"date\\":\\"2020-20-01T12:12:12.123\\",\\"message\\":\\"test1\\",\\"bytes\\":20}}]"` + ); + }); + }); +}); diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx index 1888ae8562a37f..300c40a28c6626 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx @@ -37,6 +37,7 @@ import { defaultPageSize, gridStyle, pageSizeArr, toolbarVisibility } from './co import { DiscoverServices } from '../../../build_services'; import { getDisplayedColumns } from '../../helpers/columns'; import { KibanaContextProvider } from '../../../../../kibana_react/public'; +import { DiscoverGridDocumentToolbarBtn, getDocId } from './discover_grid_document_selection'; interface SortObj { id: string; @@ -158,14 +159,27 @@ export const DiscoverGrid = ({ sort, useNewFieldsApi, }: DiscoverGridProps) => { + const [selectedDocs, setSelectedDocs] = useState([]); + const [isFilterActive, setIsFilterActive] = useState(false); const displayedColumns = getDisplayedColumns(columns, indexPattern); const defaultColumns = displayedColumns.includes('_source'); + const displayedRows = useMemo(() => { + if (!rows) { + return []; + } + if (!isFilterActive || selectedDocs.length === 0) { + return rows; + } + return rows.filter((row) => { + return selectedDocs.includes(getDocId(row)); + }); + }, [rows, selectedDocs, isFilterActive]); /** * Pagination */ const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: defaultPageSize }); - const rowCount = useMemo(() => (rows ? rows.length : 0), [rows]); + const rowCount = useMemo(() => (displayedRows ? displayedRows.length : 0), [displayedRows]); const pageCount = useMemo(() => Math.ceil(rowCount / pagination.pageSize), [ rowCount, pagination, @@ -207,11 +221,11 @@ export const DiscoverGrid = ({ () => getRenderCellValueFn( indexPattern, - rows, - rows ? rows.map((hit) => indexPattern.flattenHit(hit)) : [], + displayedRows, + displayedRows ? displayedRows.map((hit) => indexPattern.flattenHit(hit)) : [], useNewFieldsApi ), - [rows, indexPattern, useNewFieldsApi] + [displayedRows, indexPattern, useNewFieldsApi] ); /** @@ -240,6 +254,20 @@ export const DiscoverGrid = ({ ]); const lead = useMemo(() => getLeadControlColumns(), []); + const additionalControls = useMemo( + () => + selectedDocs.length ? ( + + ) : null, + [selectedDocs, isFilterActive, rows, setIsFilterActive] + ); + if (!rowCount) { return (
@@ -257,10 +285,17 @@ export const DiscoverGrid = ({ value={{ expanded: expandedDoc, setExpanded: setExpandedDoc, - rows: rows || [], + rows: displayedRows, onFilter, indexPattern, isDarkMode: services.uiSettings.get('theme:darkMode'), + selectedDocs, + setSelectedDocs: (newSelectedDocs) => { + setSelectedDocs(newSelectedDocs); + if (isFilterActive && newSelectedDocs.length === 0) { + setIsFilterActive(false); + } + }, }} > @@ -335,7 +375,7 @@ export const DiscoverGrid = ({ ( + + + {i18n.translate('discover.selectColumnHeader', { + defaultMessage: 'Select column', + })} + + + ), + }, ]; } diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_context.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_context.tsx index 46169e1e1325f5..e57d3fb8362aed 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_context.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_context.tsx @@ -17,6 +17,8 @@ export interface GridContext { onFilter: DocViewFilterFn; indexPattern: IndexPattern; isDarkMode: boolean; + selectedDocs: string[]; + setSelectedDocs: (selected: string[]) => void; } const defaultContext = ({} as unknown) as GridContext; diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_document_selection.test.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_document_selection.test.tsx new file mode 100644 index 00000000000000..9ebe3ee95f7974 --- /dev/null +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_document_selection.test.tsx @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { mountWithIntl } from '@kbn/test/jest'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { + DiscoverGridDocumentToolbarBtn, + getDocId, + SelectButton, +} from './discover_grid_document_selection'; +import { esHits } from '../../../__mocks__/es_hits'; +import { indexPatternMock } from '../../../__mocks__/index_pattern'; +import { DiscoverGridContext } from './discover_grid_context'; + +describe('document selection', () => { + describe('getDocId', () => { + test('doc with custom routing', () => { + const doc = { + _id: 'test-id', + _index: 'test-indices', + _routing: 'why-not', + }; + expect(getDocId(doc)).toMatchInlineSnapshot(`"test-indices::test-id::why-not"`); + }); + test('doc without custom routing', () => { + const doc = { + _id: 'test-id', + _index: 'test-indices', + }; + expect(getDocId(doc)).toMatchInlineSnapshot(`"test-indices::test-id::"`); + }); + }); + + describe('SelectButton', () => { + test('is not checked', () => { + const contextMock = { + expanded: undefined, + setExpanded: jest.fn(), + rows: esHits, + onFilter: jest.fn(), + indexPattern: indexPatternMock, + isDarkMode: false, + selectedDocs: [], + setSelectedDocs: jest.fn(), + }; + + const component = mountWithIntl( + + + + ); + + const checkBox = findTestSubject(component, 'dscGridSelectDoc-i::1::'); + expect(checkBox.props().checked).toBeFalsy(); + }); + + test('is checked', () => { + const contextMock = { + expanded: undefined, + setExpanded: jest.fn(), + rows: esHits, + onFilter: jest.fn(), + indexPattern: indexPatternMock, + isDarkMode: false, + selectedDocs: ['i::1::'], + setSelectedDocs: jest.fn(), + }; + + const component = mountWithIntl( + + + + ); + + const checkBox = findTestSubject(component, 'dscGridSelectDoc-i::1::'); + expect(checkBox.props().checked).toBeTruthy(); + }); + + test('adding a selection', () => { + const contextMock = { + expanded: undefined, + setExpanded: jest.fn(), + rows: esHits, + onFilter: jest.fn(), + indexPattern: indexPatternMock, + isDarkMode: false, + selectedDocs: [], + setSelectedDocs: jest.fn(), + }; + + const component = mountWithIntl( + + + + ); + + const checkBox = findTestSubject(component, 'dscGridSelectDoc-i::1::'); + checkBox.simulate('change'); + expect(contextMock.setSelectedDocs).toHaveBeenCalledWith(['i::1::']); + }); + test('removing a selection', () => { + const contextMock = { + expanded: undefined, + setExpanded: jest.fn(), + rows: esHits, + onFilter: jest.fn(), + indexPattern: indexPatternMock, + isDarkMode: false, + selectedDocs: ['i::1::'], + setSelectedDocs: jest.fn(), + }; + + const component = mountWithIntl( + + + + ); + + const checkBox = findTestSubject(component, 'dscGridSelectDoc-i::1::'); + checkBox.simulate('change'); + expect(contextMock.setSelectedDocs).toHaveBeenCalledWith([]); + }); + }); + describe('DiscoverGridDocumentToolbarBtn', () => { + test('it renders a button clickable button', () => { + const props = { + isFilterActive: false, + rows: esHits, + selectedDocs: ['i::1::'], + setIsFilterActive: jest.fn(), + setSelectedDocs: jest.fn(), + }; + const component = mountWithIntl(); + const button = findTestSubject(component, 'dscGridSelectionBtn'); + expect(button.length).toBe(1); + }); + }); +}); diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_document_selection.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_document_selection.tsx new file mode 100644 index 00000000000000..4aaefc99479c1d --- /dev/null +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_document_selection.tsx @@ -0,0 +1,170 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React, { useCallback, useState, useContext, useMemo } from 'react'; +import { + EuiButtonEmpty, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiCopy, + EuiPopover, + EuiCheckbox, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import classNames from 'classnames'; +import { ElasticSearchHit } from '../../doc_views/doc_views_types'; +import { DiscoverGridContext } from './discover_grid_context'; + +/** + * Returning a generated id of a given ES document, since `_id` can be the same + * when using different indices and shard routing + */ +export const getDocId = (doc: ElasticSearchHit & { _routing?: string }) => { + const routing = doc._routing ? doc._routing : ''; + return [doc._index, doc._id, routing].join('::'); +}; +export const SelectButton = ({ rowIndex }: { rowIndex: number }) => { + const ctx = useContext(DiscoverGridContext); + const doc = useMemo(() => ctx.rows[rowIndex], [ctx.rows, rowIndex]); + const id = useMemo(() => getDocId(doc), [doc]); + const checked = useMemo(() => ctx.selectedDocs.includes(id), [ctx.selectedDocs, id]); + + return ( + { + if (checked) { + const newSelection = ctx.selectedDocs.filter((docId) => docId !== id); + ctx.setSelectedDocs(newSelection); + } else { + ctx.setSelectedDocs([...ctx.selectedDocs, id]); + } + }} + /> + ); +}; + +export function DiscoverGridDocumentToolbarBtn({ + isFilterActive, + rows, + selectedDocs, + setIsFilterActive, + setSelectedDocs, +}: { + isFilterActive: boolean; + rows: ElasticSearchHit[]; + selectedDocs: string[]; + setIsFilterActive: (value: boolean) => void; + setSelectedDocs: (value: string[]) => void; +}) { + const [isSelectionPopoverOpen, setIsSelectionPopoverOpen] = useState(false); + + const getMenuItems = useCallback(() => { + return [ + isFilterActive ? ( + { + setIsSelectionPopoverOpen(false); + setIsFilterActive(false); + }} + > + + + ) : ( + { + setIsSelectionPopoverOpen(false); + setIsFilterActive(true); + }} + > + + + ), + + { + setIsSelectionPopoverOpen(false); + setSelectedDocs([]); + setIsFilterActive(false); + }} + > + + , + selectedDocs.includes(getDocId(row)))) : '' + } + > + {(copy) => ( + + + + )} + , + ]; + }, [ + isFilterActive, + rows, + selectedDocs, + setIsFilterActive, + setIsSelectionPopoverOpen, + setSelectedDocs, + ]); + + return ( + setIsSelectionPopoverOpen(false)} + isOpen={isSelectionPopoverOpen} + panelPaddingSize="none" + button={ + setIsSelectionPopoverOpen(true)} + data-selected-documents={selectedDocs.length} + data-test-subj="dscGridSelectionBtn" + isSelected={isFilterActive} + className={classNames({ + // eslint-disable-next-line @typescript-eslint/naming-convention + euiDataGrid__controlBtn: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'euiDataGrid__controlBtn--active': isFilterActive, + })} + > + + + } + > + {isSelectionPopoverOpen && } + + ); +} diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_expand_button.test.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_expand_button.test.tsx index 98a12054838085..d1299b39a25b2e 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_expand_button.test.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_expand_button.test.tsx @@ -23,6 +23,8 @@ describe('Discover grid view button ', function () { onFilter: jest.fn(), indexPattern: indexPatternMock, isDarkMode: false, + selectedDocs: [], + setSelectedDocs: jest.fn(), }; const component = mountWithIntl( @@ -49,6 +51,8 @@ describe('Discover grid view button ', function () { onFilter: jest.fn(), indexPattern: indexPatternMock, isDarkMode: false, + selectedDocs: [], + setSelectedDocs: jest.fn(), }; const component = mountWithIntl( @@ -75,6 +79,8 @@ describe('Discover grid view button ', function () { onFilter: jest.fn(), indexPattern: indexPatternMock, isDarkMode: false, + selectedDocs: [], + setSelectedDocs: jest.fn(), }; const component = mountWithIntl( diff --git a/test/functional/apps/dashboard/embeddable_data_grid.ts b/test/functional/apps/dashboard/embeddable_data_grid.ts index 00a75baae4be7f..a9e0039de1f79f 100644 --- a/test/functional/apps/dashboard/embeddable_data_grid.ts +++ b/test/functional/apps/dashboard/embeddable_data_grid.ts @@ -47,12 +47,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('are added when a cell filter is clicked', async function () { - await find.clickByCssSelector(`[role="gridcell"]:nth-child(3)`); + await find.clickByCssSelector(`[role="gridcell"]:nth-child(4)`); // needs a short delay between becoming visible & being clickable await PageObjects.common.sleep(250); await find.clickByCssSelector(`[data-test-subj="filterOutButton"]`); await PageObjects.header.waitUntilLoadingHasFinished(); - await find.clickByCssSelector(`[role="gridcell"]:nth-child(3)`); + await find.clickByCssSelector(`[role="gridcell"]:nth-child(4)`); await PageObjects.common.sleep(250); await find.clickByCssSelector(`[data-test-subj="filterForButton"]`); const filterCount = await filterBar.getFilterCount(); diff --git a/test/functional/apps/discover/_data_grid_field_data.ts b/test/functional/apps/discover/_data_grid_field_data.ts index e8fcb06d06193b..f41a98e2f3364c 100644 --- a/test/functional/apps/discover/_data_grid_field_data.ts +++ b/test/functional/apps/discover/_data_grid_field_data.ts @@ -68,7 +68,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.discover.waitUntilSearchingHasFinished(); await retry.waitFor('first cell contains expected timestamp', async () => { - const cell = await dataGrid.getCellElement(1, 2); + const cell = await dataGrid.getCellElement(1, 3); const text = await cell.getVisibleText(); return text === expectedTimeStamp; }); diff --git a/test/functional/services/data_grid.ts b/test/functional/services/data_grid.ts index c0a7e0f82e6920..87fa59b48a3249 100644 --- a/test/functional/services/data_grid.ts +++ b/test/functional/services/data_grid.ts @@ -168,7 +168,7 @@ export function DataGridProvider({ getService, getPageObjects }: FtrProviderCont const textArr = []; let idx = 0; for (const cell of result) { - if (idx > 0) { + if (idx > 1) { textArr.push(await cell.getVisibleText()); } idx++; From 22dd61d919a2b3e04b85b3b1dc6dcd63c988a406 Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Tue, 13 Apr 2021 06:55:50 -0700 Subject: [PATCH 27/90] [keystore] Fix openHandle in Jest tests (#96671) ``` [2021-04-07T00:19:27Z] Jest did not exit one second after the test run has completed. [2021-04-07T00:19:27Z] [2021-04-07T00:19:27Z] This usually means that there are asynchronous operations that weren't stopped in your tests. Consider running Jest with `--detectOpenHandles` to troubleshoot this issue. ``` Signed-off-by: Tyler Smalley Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- src/cli_keystore/utils/prompt.js | 1 + src/cli_keystore/utils/prompt.test.js | 36 +++++++++++---------------- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/src/cli_keystore/utils/prompt.js b/src/cli_keystore/utils/prompt.js index d681f7de2e32cb..195f794db3e6e5 100644 --- a/src/cli_keystore/utils/prompt.js +++ b/src/cli_keystore/utils/prompt.js @@ -75,6 +75,7 @@ export function question(question, options = {}) { }); rl.question(questionPrompt, (value) => { + rl.close(); resolve(value); }); }); diff --git a/src/cli_keystore/utils/prompt.test.js b/src/cli_keystore/utils/prompt.test.js index 306d4b2bd66df5..e7ccac4e83e113 100644 --- a/src/cli_keystore/utils/prompt.test.js +++ b/src/cli_keystore/utils/prompt.test.js @@ -6,14 +6,11 @@ * Side Public License, v 1. */ -import sinon from 'sinon'; import { PassThrough } from 'stream'; import { confirm, question } from './prompt'; describe('prompt', () => { - const sandbox = sinon.createSandbox(); - let input; let output; @@ -23,30 +20,27 @@ describe('prompt', () => { }); afterEach(() => { - sandbox.restore(); + input.end(); + output.end(); }); describe('confirm', () => { it('prompts for question', async () => { - const onData = sandbox.stub(output, 'write'); - - confirm('my question', { output }); + const write = jest.spyOn(output, 'write'); - sinon.assert.calledOnce(onData); + process.nextTick(() => input.write('Y\n')); + await confirm('my question', { input, output }); - const { args } = onData.getCall(0); - expect(args[0]).toEqual('my question [y/N] '); + expect(write).toHaveBeenCalledWith('my question [y/N] '); }); it('prompts for question with default true', async () => { - const onData = sandbox.stub(output, 'write'); - - confirm('my question', { output, default: true }); + const write = jest.spyOn(output, 'write'); - sinon.assert.calledOnce(onData); + process.nextTick(() => input.write('Y\n')); + await confirm('my question', { input, output, default: true }); - const { args } = onData.getCall(0); - expect(args[0]).toEqual('my question [Y/n] '); + expect(write).toHaveBeenCalledWith('my question [Y/n] '); }); it('defaults to false', async () => { @@ -87,14 +81,12 @@ describe('prompt', () => { describe('question', () => { it('prompts for question', async () => { - const onData = sandbox.stub(output, 'write'); - - question('my question', { output }); + const write = jest.spyOn(output, 'write'); - sinon.assert.calledOnce(onData); + process.nextTick(() => input.write('my answer\n')); + await question('my question', { input, output }); - const { args } = onData.getCall(0); - expect(args[0]).toEqual('my question: '); + expect(write).toHaveBeenCalledWith('my question: '); }); it('can be answered', async () => { From ba091c00cf3ccf94f7dfe3c5e3effa36cda1233f Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Tue, 13 Apr 2021 15:04:07 +0100 Subject: [PATCH 28/90] [K8] [Maps] Fix toolbar overlay styles (#96352) * Fix toolbar overlay styles * More styles * Updating test * Better focus state for mapbox buttons * Mapbox buttons focus * Focus againa * Focus states again * no background only for focus not hover * Adding mixin for button group border radius Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../maps/public/{index.scss => _index.scss} | 1 + x-pack/plugins/maps/public/_mapbox_hacks.scss | 19 ++++++- x-pack/plugins/maps/public/_mixins.scss | 11 +++++ .../toolbar_overlay/_index.scss | 22 +-------- .../toolbar_overlay/_toolbar_overlay.scss | 49 +++++++++++++++++++ .../fit_to_data/fit_to_data.tsx | 30 ++++++------ .../set_view_control/set_view_control.tsx | 29 ++++++----- .../__snapshots__/tools_control.test.tsx.snap | 38 ++++++++------ .../tools_control/tools_control.tsx | 27 +++++----- .../public/lazy_load_bundle/lazy/index.ts | 2 +- 10 files changed, 152 insertions(+), 76 deletions(-) rename x-pack/plugins/maps/public/{index.scss => _index.scss} (94%) create mode 100644 x-pack/plugins/maps/public/_mixins.scss create mode 100644 x-pack/plugins/maps/public/connected_components/toolbar_overlay/_toolbar_overlay.scss diff --git a/x-pack/plugins/maps/public/index.scss b/x-pack/plugins/maps/public/_index.scss similarity index 94% rename from x-pack/plugins/maps/public/index.scss rename to x-pack/plugins/maps/public/_index.scss index d2dd07b0f81f91..5332464ade9fba 100644 --- a/x-pack/plugins/maps/public/index.scss +++ b/x-pack/plugins/maps/public/_index.scss @@ -7,6 +7,7 @@ // mapChart__legend--small // mapChart__legend-isLoading +@import 'mixins'; @import 'main'; @import 'mapbox_hacks'; @import 'connected_components/index'; diff --git a/x-pack/plugins/maps/public/_mapbox_hacks.scss b/x-pack/plugins/maps/public/_mapbox_hacks.scss index 9b2d93986e4263..480232007995d0 100644 --- a/x-pack/plugins/maps/public/_mapbox_hacks.scss +++ b/x-pack/plugins/maps/public/_mapbox_hacks.scss @@ -10,8 +10,13 @@ .mapboxgl-ctrl-group:not(:empty) { @include euiBottomShadowLarge; + @include mapToolbarButtonGroupBorderRadius; background-color: $euiColorEmptyShade; - border-radius: $euiBorderRadius; + transition: transform $euiAnimSpeedNormal ease-in-out; + + &:hover { + transform: translateY(-1px); + } > button { @include size($euiSizeXL); @@ -21,6 +26,18 @@ } } } + + .mapboxgl-ctrl button:not(:disabled) { + transition: background $euiAnimSpeedNormal ease-in-out; + + &:hover { + background-color: transparentize($euiColorDarkShade, .9); + } + } + + .mapboxgl-ctrl-group button:focus:focus-visible { + box-shadow: none; + } } // Custom SVG as background for zoom controls based off of EUI glyphs plusInCircleFilled and minusInCircleFilled diff --git a/x-pack/plugins/maps/public/_mixins.scss b/x-pack/plugins/maps/public/_mixins.scss new file mode 100644 index 00000000000000..914bc23c1163cf --- /dev/null +++ b/x-pack/plugins/maps/public/_mixins.scss @@ -0,0 +1,11 @@ +@mixin mapToolbarButtonGroupBorderRadius { + @include kbnThemeStyle($theme: 'v7') { + border-radius: $euiBorderRadius; + } + + @include kbnThemeStyle($theme: 'v8') { + border-radius: $euiBorderRadiusSmall; + } + + overflow: hidden; +} diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/_index.scss b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/_index.scss index e92e89b1703709..a472f1b640f682 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/_index.scss +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/_index.scss @@ -1,22 +1,2 @@ @import 'tools_control/index'; - -.mapToolbarOverlay { - position: absolute; - top: ($euiSizeM + $euiSizeS) + ($euiSizeXL * 2); // Position and height of mapbox controls plus margin - left: $euiSizeM; - z-index: 2; // Sit on top of mapbox controls shadow -} - -.mapToolbarOverlay__button { - @include size($euiSizeXL); - // sass-lint:disable-block no-important - background-color: $euiColorEmptyShade !important; - pointer-events: all; - position: relative; - - &:enabled, - &:enabled:hover, - &:enabled:focus { - @include euiBottomShadowLarge; - } -} +@import 'toolbar_overlay'; diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/_toolbar_overlay.scss b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/_toolbar_overlay.scss new file mode 100644 index 00000000000000..d95dd2504babc3 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/_toolbar_overlay.scss @@ -0,0 +1,49 @@ +.mapToolbarOverlay { + position: absolute; + top: ($euiSizeM + $euiSizeS) + ($euiSizeXL * 2); // Position and height of mapbox controls plus margin + left: $euiSizeM; + z-index: 2; // Sit on top of mapbox controls shadow +} + +.mapToolbarOverlay__button, +.mapToolbarOverlay__buttonGroup { + position: relative; + transition: transform $euiAnimSpeedNormal ease-in-out, background $euiAnimSpeedNormal ease-in-out; + + @include kbnThemeStyle($theme: 'v7') { + // Overrides the .euiPanel default border + // sass-lint:disable-block no-important + border: none !important; + + // Overrides the .euiPanel--hasShadow + &.euiPanel.euiPanel--hasShadow { + @include euiBottomShadowLarge; + } + } + + &:hover { + transform: translateY(-1px); + } + + // Removes the hover effect from the .euiButtonIcon because it would create a 1px bottom gap + // So we put this hover effect into the panel that wraps the button or buttons + .euiButtonIcon:hover { + transform: translateY(0); + } + + // Removes the focus background state because it can induce users to think these buttons are "enabled". + // The buttons functionality are just applied once, so they shouldn't stay highlighted. + .euiButtonIcon:focus:not(:hover) { + background: none; + } +} + +.mapToolbarOverlay__buttonGroup { + @include mapToolbarButtonGroupBorderRadius; + display: flex; + flex-direction: column; + + .euiButtonIcon { + border-radius: 0; + } +} \ No newline at end of file diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/fit_to_data.tsx b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/fit_to_data.tsx index 9d074ac760612d..64e163cd96a92d 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/fit_to_data.tsx +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/fit_to_data.tsx @@ -7,7 +7,7 @@ import React from 'react'; -import { EuiButtonIcon } from '@elastic/eui'; +import { EuiButtonIcon, EuiPanel } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ILayer } from '../../../classes/layers/layer'; @@ -56,19 +56,21 @@ export class FitToData extends React.Component { } return ( - + + + ); } } diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/set_view_control/set_view_control.tsx b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/set_view_control/set_view_control.tsx index b657d6369f8aa3..de37ec5e00877d 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/set_view_control/set_view_control.tsx +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/set_view_control/set_view_control.tsx @@ -15,6 +15,7 @@ import { EuiPopover, EuiTextAlign, EuiSpacer, + EuiPanel, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -190,19 +191,21 @@ export class SetViewControl extends Component { anchorPosition="leftUp" panelPaddingSize="s" button={ - + + + } isOpen={this.state.isPopoverOpen} closePopover={this._closePopover} diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/__snapshots__/tools_control.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/__snapshots__/tools_control.test.tsx.snap index 456138e191810d..b6d217d6907647 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/__snapshots__/tools_control.test.tsx.snap +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/__snapshots__/tools_control.test.tsx.snap @@ -8,14 +8,19 @@ exports[`Should render cancel button when drawing 1`] = ` + paddingSize="none" + > + + } closePopover={[Function]} display="inlineBlock" @@ -134,14 +139,19 @@ exports[`renders 1`] = ` + paddingSize="none" + > + + } closePopover={[Function]} display="inlineBlock" diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.tsx b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.tsx index 1d2354ba3154a5..6779fe945137e8 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.tsx +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.tsx @@ -13,6 +13,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiButton, + EuiPanel, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -205,18 +206,20 @@ export class ToolsControl extends Component { _renderToolsButton() { return ( - + + + ); } diff --git a/x-pack/plugins/maps/public/lazy_load_bundle/lazy/index.ts b/x-pack/plugins/maps/public/lazy_load_bundle/lazy/index.ts index e7f5df49527b71..4ccc19ae988da8 100644 --- a/x-pack/plugins/maps/public/lazy_load_bundle/lazy/index.ts +++ b/x-pack/plugins/maps/public/lazy_load_bundle/lazy/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import '../../index.scss'; +import '../../_index.scss'; export * from '../../embeddable/map_embeddable'; export * from '../../kibana_services'; export { renderApp } from '../../render_app'; From 3acabf32b4df97a616eceaa43eb6ecf608018012 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Tue, 13 Apr 2021 10:12:22 -0400 Subject: [PATCH 29/90] ensure ROC chart gets loaded correctly (#96890) --- .../application/data_frame_analytics/common/analytics.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index 505673f440ef2c..61abf8476c632d 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -523,6 +523,9 @@ export const loadEvalData = async ({ [jobType]: { actual_field: dependentVariable, predicted_field: predictedField, + ...(jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION + ? { top_classes_field: `${resultsField}.top_classes` } + : {}), metrics: metrics[jobType as keyof EvaluateMetrics], }, }, From 98f799953bbc93b99ae01c2e56e8414982fcf9ac Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Tue, 13 Apr 2021 16:13:25 +0200 Subject: [PATCH 30/90] [Search Sessions] Remove auto-refresh limitation (#96539) --- x-pack/plugins/data_enhanced/public/plugin.ts | 1 - ...onnected_search_session_indicator.test.tsx | 42 ------------------- .../connected_search_session_indicator.tsx | 20 +-------- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 5 files changed, 1 insertion(+), 64 deletions(-) diff --git a/x-pack/plugins/data_enhanced/public/plugin.ts b/x-pack/plugins/data_enhanced/public/plugin.ts index 439cae4f414f72..82f04d82ea2f8a 100644 --- a/x-pack/plugins/data_enhanced/public/plugin.ts +++ b/x-pack/plugins/data_enhanced/public/plugin.ts @@ -84,7 +84,6 @@ export class DataEnhancedPlugin sessionService: plugins.data.search.session, application: core.application, basePath: core.http.basePath, - timeFilter: plugins.data.query.timefilter.timefilter, storage: this.storage, disableSaveAfterSessionCompletesTimeout: moment .duration(this.config.search.sessions.notTouchedTimeout) diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx index c96d821641dd61..a16557b50700ea 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx @@ -60,7 +60,6 @@ test("shouldn't show indicator in case no active search session", async () => { const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService, application, - timeFilter, storage, disableSaveAfterSessionCompletesTimeout, usageCollector, @@ -89,7 +88,6 @@ test("shouldn't show indicator in case app hasn't opt-in", async () => { const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService, application, - timeFilter, storage, disableSaveAfterSessionCompletesTimeout, usageCollector, @@ -120,7 +118,6 @@ test('should show indicator in case there is an active search session', async () const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, application, - timeFilter, storage, disableSaveAfterSessionCompletesTimeout, usageCollector, @@ -146,7 +143,6 @@ test('should be disabled in case uiConfig says so ', async () => { const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, application, - timeFilter, storage, disableSaveAfterSessionCompletesTimeout, usageCollector, @@ -171,7 +167,6 @@ test('should be disabled in case not enough permissions', async () => { const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$, hasAccess: () => false }, application, - timeFilter, storage, disableSaveAfterSessionCompletesTimeout, basePath, @@ -191,38 +186,6 @@ test('should be disabled in case not enough permissions', async () => { expect(screen.getByRole('button', { name: 'Manage sessions' })).toBeDisabled(); }); -test('should be disabled during auto-refresh', async () => { - const state$ = new BehaviorSubject(SearchSessionState.Loading); - - const SearchSessionIndicator = createConnectedSearchSessionIndicator({ - sessionService: { ...sessionService, state$ }, - application, - timeFilter, - storage, - disableSaveAfterSessionCompletesTimeout, - usageCollector, - basePath, - }); - - render( - - - - ); - - await waitFor(() => screen.getByTestId('searchSessionIndicator')); - - await userEvent.click(screen.getByLabelText('Search session loading')); - - expect(screen.getByRole('button', { name: 'Save session' })).not.toBeDisabled(); - - act(() => { - refreshInterval$.next({ value: 0, pause: false }); - }); - - expect(screen.getByRole('button', { name: 'Save session' })).toBeDisabled(); -}); - describe('Completed inactivity', () => { beforeEach(() => { jest.useFakeTimers(); @@ -236,7 +199,6 @@ describe('Completed inactivity', () => { const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, application, - timeFilter, storage, disableSaveAfterSessionCompletesTimeout, usageCollector, @@ -298,7 +260,6 @@ describe('tour steps', () => { const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, application, - timeFilter, storage, disableSaveAfterSessionCompletesTimeout, usageCollector, @@ -340,7 +301,6 @@ describe('tour steps', () => { const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, application, - timeFilter, storage, disableSaveAfterSessionCompletesTimeout, usageCollector, @@ -376,7 +336,6 @@ describe('tour steps', () => { const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, application, - timeFilter, storage, disableSaveAfterSessionCompletesTimeout, usageCollector, @@ -404,7 +363,6 @@ describe('tour steps', () => { const SearchSessionIndicator = createConnectedSearchSessionIndicator({ sessionService: { ...sessionService, state$ }, application, - timeFilter, storage, disableSaveAfterSessionCompletesTimeout, usageCollector, diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx index 630aea417c84e1..603df09e1c4c65 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx @@ -6,7 +6,7 @@ */ import React, { useCallback, useEffect, useState } from 'react'; -import { debounce, distinctUntilChanged, map, mapTo, switchMap, tap } from 'rxjs/operators'; +import { debounce, distinctUntilChanged, mapTo, switchMap, tap } from 'rxjs/operators'; import { merge, of, timer } from 'rxjs'; import useObservable from 'react-use/lib/useObservable'; import { i18n } from '@kbn/i18n'; @@ -14,7 +14,6 @@ import { SearchSessionIndicator, SearchSessionIndicatorRef } from '../search_ses import { ISessionService, SearchSessionState, - TimefilterContract, SearchUsageCollector, } from '../../../../../../../src/plugins/data/public'; import { RedirectAppLinks } from '../../../../../../../src/plugins/kibana_react/public'; @@ -24,7 +23,6 @@ import { useSearchSessionTour } from './search_session_tour'; export interface SearchSessionIndicatorDeps { sessionService: ISessionService; - timeFilter: TimefilterContract; application: ApplicationStart; basePath: IBasePath; storage: IStorageWrapper; @@ -39,17 +37,12 @@ export interface SearchSessionIndicatorDeps { export const createConnectedSearchSessionIndicator = ({ sessionService, application, - timeFilter, storage, disableSaveAfterSessionCompletesTimeout, usageCollector, basePath, }: SearchSessionIndicatorDeps): React.FC => { const searchSessionsManagementUrl = basePath.prepend('/app/management/kibana/search_sessions'); - const isAutoRefreshEnabled = () => !timeFilter.getRefreshInterval().pause; - const isAutoRefreshEnabled$ = timeFilter - .getRefreshIntervalUpdate$() - .pipe(map(isAutoRefreshEnabled), distinctUntilChanged()); const debouncedSessionServiceState$ = sessionService.state$.pipe( debounce((_state) => timer(_state === SearchSessionState.None ? 50 : 300)) // switch to None faster to quickly remove indicator when navigating away @@ -69,7 +62,6 @@ export const createConnectedSearchSessionIndicator = ({ return () => { const state = useObservable(debouncedSessionServiceState$, SearchSessionState.None); - const autoRefreshEnabled = useObservable(isAutoRefreshEnabled$, isAutoRefreshEnabled()); const isSaveDisabledByApp = sessionService.getSearchSessionIndicatorUiConfig().isDisabled(); const disableSaveAfterSessionCompleteTimedOut = useObservable( disableSaveAfterSessionCompleteTimedOut$, @@ -91,16 +83,6 @@ export const createConnectedSearchSessionIndicator = ({ let managementDisabled = false; let managementDisabledReasonText: string = ''; - if (autoRefreshEnabled) { - saveDisabled = true; - saveDisabledReasonText = i18n.translate( - 'xpack.data.searchSessionIndicator.disabledDueToAutoRefreshMessage', - { - defaultMessage: 'Saving search session is not available when auto refresh is enabled.', - } - ); - } - if (disableSaveAfterSessionCompleteTimedOut) { saveDisabled = true; saveDisabledReasonText = i18n.translate( diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index a0f535e93a8a63..7eb1fb458351a3 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7344,7 +7344,6 @@ "xpack.data.searchSessionIndicator.canceledTitleText": "検索セッションが停止しました", "xpack.data.searchSessionIndicator.canceledTooltipText": "検索セッションが停止しました", "xpack.data.searchSessionIndicator.continueInBackgroundButtonText": "セッションの保存", - "xpack.data.searchSessionIndicator.disabledDueToAutoRefreshMessage": "自動更新が有効な場合は、検索セッションの保存を使用できません。", "xpack.data.searchSessionIndicator.disabledDueToDisabledGloballyMessage": "検索セッションを管理するアクセス権がありません", "xpack.data.searchSessionIndicator.disabledDueToTimeoutMessage": "検索セッション結果が期限切れです。", "xpack.data.searchSessionIndicator.loadingInTheBackgroundDescriptionText": "管理から完了した結果に戻ることができます。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 31bc197f2ea059..7e80a52d229c46 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7408,7 +7408,6 @@ "xpack.data.searchSessionIndicator.canceledTitleText": "搜索会话已停止", "xpack.data.searchSessionIndicator.canceledTooltipText": "搜索会话已停止", "xpack.data.searchSessionIndicator.continueInBackgroundButtonText": "保存会话", - "xpack.data.searchSessionIndicator.disabledDueToAutoRefreshMessage": "启用自动刷新时,保存搜索会话不可用。", "xpack.data.searchSessionIndicator.disabledDueToDisabledGloballyMessage": "您无权管理搜索会话", "xpack.data.searchSessionIndicator.disabledDueToTimeoutMessage": "搜索会话结果已过期。", "xpack.data.searchSessionIndicator.loadingInTheBackgroundDescriptionText": "可以从“管理”中返回至完成的结果。", From bedf92f0010c927f1f95c30227416b16ad2db37f Mon Sep 17 00:00:00 2001 From: Craig Chamberlain Date: Tue, 13 Apr 2021 10:35:01 -0400 Subject: [PATCH 31/90] Adds Network ML module with four ML jobs for ECS network data (#96480) * network module adds the network module with four ml jobs for the 7.13 release * Update datafeed_high_count_network_denies.json json formatting * update test added the security_network module to the list * renames module name change to security_network / Security: Network * formatting change hyphen char to underscores * fixes and name changes fixes to df queries, descriptions. created_by param * update tests tests need the security_network module added * formatting change hyphens to underscores * descriptions format descriptions * Update datafeed_high_count_network_events.json indentation fixes * Update x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/manifest.json Co-authored-by: Lisa Cawley * Update x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_denies.json Co-authored-by: Lisa Cawley * Update datafeed_high_count_network_events.json change to a filter Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Lisa Cawley --- .../modules/security_network/logo.json | 3 + .../modules/security_network/manifest.json | 63 +++++++++++++++++++ ...eed_high_count_by_destination_country.json | 25 ++++++++ .../datafeed_high_count_network_denies.json | 25 ++++++++ .../datafeed_high_count_network_events.json | 20 ++++++ .../ml/datafeed_rare_destination_country.json | 25 ++++++++ .../ml/high_count_by_destination_country.json | 35 +++++++++++ .../ml/high_count_network_denies.json | 34 ++++++++++ .../ml/high_count_network_events.json | 34 ++++++++++ .../ml/rare_destination_country.json | 35 +++++++++++ .../apis/ml/modules/get_module.ts | 1 + .../apis/ml/modules/recognize_module.ts | 2 +- 12 files changed, 301 insertions(+), 1 deletion(-) create mode 100755 x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/logo.json create mode 100755 x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/manifest.json create mode 100755 x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/datafeed_high_count_by_destination_country.json create mode 100755 x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/datafeed_high_count_network_denies.json create mode 100755 x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/datafeed_high_count_network_events.json create mode 100755 x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/datafeed_rare_destination_country.json create mode 100755 x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_by_destination_country.json create mode 100755 x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_denies.json create mode 100755 x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_events.json create mode 100755 x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/rare_destination_country.json diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/logo.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/logo.json new file mode 100755 index 00000000000000..862f970b7405db --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/logo.json @@ -0,0 +1,3 @@ +{ + "icon": "logoSecurity" +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/manifest.json new file mode 100755 index 00000000000000..55f07ab077d40b --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/manifest.json @@ -0,0 +1,63 @@ +{ + "id": "security_network", + "title": "Security: Network", + "description": "Detect anomalous network activity in your ECS-compatible network logs.", + "type": "network data", + "logoFile": "logo.json", + "defaultIndexPattern": [ + "logs-*", + "filebeat-*", + "packetbeat-*" + ], + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "network" + } + } + ] + } + }, + "jobs": [ + { + "id": "high_count_by_destination_country", + "file": "high_count_by_destination_country.json" + }, + { + "id": "high_count_network_denies", + "file": "high_count_network_denies.json" + }, + { + "id": "high_count_network_events", + "file": "high_count_network_events.json" + }, + { + "id": "rare_destination_country", + "file": "rare_destination_country.json" + } + ], + "datafeeds": [ + { + "id": "datafeed_high_count_by_destination_country", + "file": "datafeed_high_count_by_destination_country.json", + "job_id": "high_count_by_destination_country" + }, + { + "id": "datafeed_high_count_network_denies", + "file": "datafeed_high_count_network_denies.json", + "job_id": "high_count_network_denies" + }, + { + "id": "datafeed_high_count_network_events", + "file": "datafeed_high_count_network_events.json", + "job_id": "high_count_network_events" + }, + { + "id": "datafeed_rare_destination_country", + "file": "datafeed_rare_destination_country.json", + "job_id": "rare_destination_country" + } + ] +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/datafeed_high_count_by_destination_country.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/datafeed_high_count_by_destination_country.json new file mode 100755 index 00000000000000..48706c6ea6b5db --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/datafeed_high_count_by_destination_country.json @@ -0,0 +1,25 @@ +{ + "job_id": "high_count_by_destination_country", + "indices": [ + "logs-*", + "filebeat-*", + "packetbeat-*" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "network" + } + }, + { + "exists": { + "field": "destination.geo.country_name" + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/datafeed_high_count_network_denies.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/datafeed_high_count_network_denies.json new file mode 100755 index 00000000000000..a4412a6d732e99 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/datafeed_high_count_network_denies.json @@ -0,0 +1,25 @@ +{ + "job_id": "high_count_network_denies", + "indices": [ + "logs-*", + "filebeat-*", + "packetbeat-*" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "network" + } + }, + { + "term": { + "event.outcome": "deny" + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/datafeed_high_count_network_events.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/datafeed_high_count_network_events.json new file mode 100755 index 00000000000000..1e3bbf92b8aeda --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/datafeed_high_count_network_events.json @@ -0,0 +1,20 @@ +{ + "job_id": "high_count_network_events", + "indices": [ + "logs-*", + "filebeat-*", + "packetbeat-*" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "network" + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/datafeed_rare_destination_country.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/datafeed_rare_destination_country.json new file mode 100755 index 00000000000000..92431a6912faae --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/datafeed_rare_destination_country.json @@ -0,0 +1,25 @@ +{ + "job_id": "rare_destination_country", + "indices": [ + "logs-*", + "filebeat-*", + "packetbeat-*" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "network" + } + }, + { + "exists": { + "field": "destination.geo.country_name" + } + } + ] + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_by_destination_country.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_by_destination_country.json new file mode 100755 index 00000000000000..aaee46d9cf80b6 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_by_destination_country.json @@ -0,0 +1,35 @@ +{ + "job_type": "anomaly_detector", + "description": "Security: Network - looks for an unusually large spike in network activity to one destination country in the network logs. This could be due to unusually large amounts of reconnaissance or enumeration traffic. Data exfiltration activity may also produce such a surge in traffic to a destination country which does not normally appear in network traffic or business work-flows. Malware instances and persistence mechanisms may communicate with command-and-control (C2) infrastructure in their country of origin, which may be an unusual destination country for the source network.", + "groups": [ + "security", + "network" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "high_non_zero_count by \"destination.geo.country_name\"", + "function": "high_non_zero_count", + "by_field_name": "destination.geo.country_name", + "detector_index": 0 + } + ], + "influencers": [ + "destination.geo.country_name", + "destination.as.organization.name", + "source.ip", + "destination.ip" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "32mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-security-network" + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_denies.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_denies.json new file mode 100755 index 00000000000000..bc08aa21f32771 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_denies.json @@ -0,0 +1,34 @@ +{ + "job_type": "anomaly_detector", + "description": "Security: Network - looks for an unusually large spike in network traffic that was denied by network ACLs or firewall rules. Such a burst of denied traffic is usually either 1) a misconfigured application or firewall or 2) suspicious or malicious activity. Unsuccessful attempts at network transit, in order to connect to command-and-control (C2), or engage in data exfiltration, may produce a burst of failed connections. This could also be due to unusually large amounts of reconnaissance or enumeration traffic. Denial-of-service attacks or traffic floods may also produce such a surge in traffic.", + "groups": [ + "security", + "network" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "high_count", + "function": "high_count", + "detector_index": 0 + } + ], + "influencers": [ + "destination.geo.country_name", + "destination.as.organization.name", + "source.ip", + "destination.port" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "32mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-security-network" + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_events.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_events.json new file mode 100755 index 00000000000000..d709eb21d7c6d9 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_events.json @@ -0,0 +1,34 @@ +{ + "job_type": "anomaly_detector", + "description": "Security: Network - looks for an unusually large spike in network traffic. Such a burst of traffic, if not caused by a surge in business activity, can be due to suspicious or malicious activity. Large-scale data exfiltration may produce a burst of network traffic; this could also be due to unusually large amounts of reconnaissance or enumeration traffic. Denial-of-service attacks or traffic floods may also produce such a surge in traffic.", + "groups": [ + "security", + "network" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "high_count", + "function": "high_count", + "detector_index": 0 + } + ], + "influencers": [ + "destination.geo.country_name", + "destination.as.organization.name", + "source.ip", + "destination.ip" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "32mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-security-network" + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/rare_destination_country.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/rare_destination_country.json new file mode 100755 index 00000000000000..15571f89b81afc --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/rare_destination_country.json @@ -0,0 +1,35 @@ +{ + "job_type": "anomaly_detector", + "description": "Security: Network - looks for an unusual destination country name in the network logs. This can be due to initial access, persistence, command-and-control, or exfiltration activity. For example, when a user clicks on a link in a phishing email or opens a malicious document, a request may be sent to download and run a payload from a server in a country which does not normally appear in network traffic or business work-flows. Malware instances and persistence mechanisms may communicate with command-and-control (C2) infrastructure in their country of origin, which may be an unusual destination country for the source network.", + "groups": [ + "security", + "network" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "rare by \"destination.geo.country_name\"", + "function": "rare", + "by_field_name": "destination.geo.country_name", + "detector_index": 0 + } + ], + "influencers": [ + "destination.geo.country_name", + "destination.as.organization.name", + "source.ip", + "destination.ip" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "32mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-security-network" + } +} diff --git a/x-pack/test/api_integration/apis/ml/modules/get_module.ts b/x-pack/test/api_integration/apis/ml/modules/get_module.ts index bd35bdddc33992..aade3723745489 100644 --- a/x-pack/test/api_integration/apis/ml/modules/get_module.ts +++ b/x-pack/test/api_integration/apis/ml/modules/get_module.ts @@ -27,6 +27,7 @@ const moduleIds = [ 'sample_data_ecommerce', 'sample_data_weblogs', 'security_linux', + 'security_network', 'security_windows', 'siem_auditbeat', 'siem_auditbeat_auth', diff --git a/x-pack/test/api_integration/apis/ml/modules/recognize_module.ts b/x-pack/test/api_integration/apis/ml/modules/recognize_module.ts index d7ba410dd5dd88..d6020e17801fd7 100644 --- a/x-pack/test/api_integration/apis/ml/modules/recognize_module.ts +++ b/x-pack/test/api_integration/apis/ml/modules/recognize_module.ts @@ -143,7 +143,7 @@ export default ({ getService }: FtrProviderContext) => { user: USER.ML_POWERUSER, expected: { responseCode: 200, - moduleIds: ['security_linux', 'security_windows'], + moduleIds: ['security_linux', 'security_network', 'security_windows'], }, }, ]; From 27c191d405db7f2a3096a269b7929f6adb1d86db Mon Sep 17 00:00:00 2001 From: Spencer Date: Tue, 13 Apr 2021 07:43:03 -0700 Subject: [PATCH 32/90] [plugin-generator] don't generate .eslintrc.js files for internal plugins (#96921) Co-authored-by: spalger --- packages/kbn-plugin-generator/src/render_template.ts | 2 +- x-pack/examples/reporting_example/.eslintrc.js | 7 ------- x-pack/examples/reporting_example/common/index.ts | 7 +++++++ x-pack/examples/reporting_example/public/application.tsx | 7 +++++++ .../examples/reporting_example/public/components/app.tsx | 7 +++++++ x-pack/examples/reporting_example/public/index.ts | 7 +++++++ x-pack/examples/reporting_example/public/plugin.ts | 7 +++++++ x-pack/examples/reporting_example/public/types.ts | 7 +++++++ x-pack/plugins/timelines/.eslintrc.js | 7 ------- x-pack/plugins/timelines/common/index.ts | 7 +++++++ x-pack/plugins/timelines/public/components/index.tsx | 7 +++++++ x-pack/plugins/timelines/public/index.ts | 7 +++++++ x-pack/plugins/timelines/public/plugin.ts | 7 +++++++ x-pack/plugins/timelines/public/types.ts | 7 +++++++ x-pack/plugins/timelines/server/config.ts | 5 +++-- x-pack/plugins/timelines/server/index.ts | 5 +++-- x-pack/plugins/timelines/server/plugin.ts | 7 +++++++ x-pack/plugins/timelines/server/routes/index.ts | 7 +++++++ x-pack/plugins/timelines/server/types.ts | 7 +++++++ 19 files changed, 105 insertions(+), 19 deletions(-) delete mode 100644 x-pack/examples/reporting_example/.eslintrc.js delete mode 100644 x-pack/plugins/timelines/.eslintrc.js diff --git a/packages/kbn-plugin-generator/src/render_template.ts b/packages/kbn-plugin-generator/src/render_template.ts index 282a547318d288..1a9716f1f1ba5a 100644 --- a/packages/kbn-plugin-generator/src/render_template.ts +++ b/packages/kbn-plugin-generator/src/render_template.ts @@ -84,7 +84,7 @@ export async function renderTemplates({ answers.ui ? [] : 'public/**/*', answers.ui && !answers.internal ? [] : ['translations/**/*', 'i18nrc.json'], answers.server ? [] : 'server/**/*', - !answers.internal ? [] : ['eslintrc.js', 'tsconfig.json', 'package.json', '.gitignore'] + !answers.internal ? [] : ['.eslintrc.js', 'tsconfig.json', 'package.json', '.gitignore'] ) ), diff --git a/x-pack/examples/reporting_example/.eslintrc.js b/x-pack/examples/reporting_example/.eslintrc.js deleted file mode 100644 index b267018448ba62..00000000000000 --- a/x-pack/examples/reporting_example/.eslintrc.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = { - root: true, - extends: ['@elastic/eslint-config-kibana', 'plugin:@elastic/eui/recommended'], - rules: { - '@kbn/eslint/require-license-header': 'off', - }, -}; diff --git a/x-pack/examples/reporting_example/common/index.ts b/x-pack/examples/reporting_example/common/index.ts index e47604bd7b8231..f01f2673eff569 100644 --- a/x-pack/examples/reporting_example/common/index.ts +++ b/x-pack/examples/reporting_example/common/index.ts @@ -1,2 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + export const PLUGIN_ID = 'reportingExample'; export const PLUGIN_NAME = 'reportingExample'; diff --git a/x-pack/examples/reporting_example/public/application.tsx b/x-pack/examples/reporting_example/public/application.tsx index 1bb944faad3eae..25a1cc767f1f51 100644 --- a/x-pack/examples/reporting_example/public/application.tsx +++ b/x-pack/examples/reporting_example/public/application.tsx @@ -1,3 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + import React from 'react'; import ReactDOM from 'react-dom'; import { AppMountParameters, CoreStart } from '../../../../src/core/public'; diff --git a/x-pack/examples/reporting_example/public/components/app.tsx b/x-pack/examples/reporting_example/public/components/app.tsx index 8f7176675f2c2d..fd4a85dd067790 100644 --- a/x-pack/examples/reporting_example/public/components/app.tsx +++ b/x-pack/examples/reporting_example/public/components/app.tsx @@ -1,3 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + import { EuiCard, EuiCode, diff --git a/x-pack/examples/reporting_example/public/index.ts b/x-pack/examples/reporting_example/public/index.ts index a490cf96895be9..f9f749e2b0cd02 100644 --- a/x-pack/examples/reporting_example/public/index.ts +++ b/x-pack/examples/reporting_example/public/index.ts @@ -1,3 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + import { ReportingExamplePlugin } from './plugin'; export function plugin() { diff --git a/x-pack/examples/reporting_example/public/plugin.ts b/x-pack/examples/reporting_example/public/plugin.ts index 95b4d917f549ae..6ac1cbe01db92f 100644 --- a/x-pack/examples/reporting_example/public/plugin.ts +++ b/x-pack/examples/reporting_example/public/plugin.ts @@ -1,3 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + import { AppMountParameters, AppNavLinkStatus, diff --git a/x-pack/examples/reporting_example/public/types.ts b/x-pack/examples/reporting_example/public/types.ts index d574053266fae8..56e8c34d9dae44 100644 --- a/x-pack/examples/reporting_example/public/types.ts +++ b/x-pack/examples/reporting_example/public/types.ts @@ -1,3 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + import { DeveloperExamplesSetup } from '../../../../examples/developer_examples/public'; import { NavigationPublicPluginStart } from '../../../../src/plugins/navigation/public'; import { ReportingStart } from '../../../plugins/reporting/public'; diff --git a/x-pack/plugins/timelines/.eslintrc.js b/x-pack/plugins/timelines/.eslintrc.js deleted file mode 100644 index b267018448ba62..00000000000000 --- a/x-pack/plugins/timelines/.eslintrc.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = { - root: true, - extends: ['@elastic/eslint-config-kibana', 'plugin:@elastic/eui/recommended'], - rules: { - '@kbn/eslint/require-license-header': 'off', - }, -}; diff --git a/x-pack/plugins/timelines/common/index.ts b/x-pack/plugins/timelines/common/index.ts index 2354c513f73b8d..c095b6c89627ea 100644 --- a/x-pack/plugins/timelines/common/index.ts +++ b/x-pack/plugins/timelines/common/index.ts @@ -1,2 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + export const PLUGIN_ID = 'timelines'; export const PLUGIN_NAME = 'timelines'; diff --git a/x-pack/plugins/timelines/public/components/index.tsx b/x-pack/plugins/timelines/public/components/index.tsx index 3388b3c44baff4..f44ad8052917f3 100644 --- a/x-pack/plugins/timelines/public/components/index.tsx +++ b/x-pack/plugins/timelines/public/components/index.tsx @@ -1,3 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + import React from 'react'; import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; diff --git a/x-pack/plugins/timelines/public/index.ts b/x-pack/plugins/timelines/public/index.ts index b535def809de3c..c3d24d49e2401f 100644 --- a/x-pack/plugins/timelines/public/index.ts +++ b/x-pack/plugins/timelines/public/index.ts @@ -1,3 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + import './index.scss'; import { PluginInitializerContext } from 'src/core/public'; diff --git a/x-pack/plugins/timelines/public/plugin.ts b/x-pack/plugins/timelines/public/plugin.ts index 7e90d9467fefdd..76a692cf8ed102 100644 --- a/x-pack/plugins/timelines/public/plugin.ts +++ b/x-pack/plugins/timelines/public/plugin.ts @@ -1,3 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + import { CoreSetup, Plugin, PluginInitializerContext } from '../../../../src/core/public'; import { TimelinesPluginSetup, TimelineProps } from './types'; import { getTimelineLazy } from './methods'; diff --git a/x-pack/plugins/timelines/public/types.ts b/x-pack/plugins/timelines/public/types.ts index b199b459027180..1fa6d33a6af602 100644 --- a/x-pack/plugins/timelines/public/types.ts +++ b/x-pack/plugins/timelines/public/types.ts @@ -1,3 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + import { ReactElement } from 'react'; export interface TimelinesPluginSetup { diff --git a/x-pack/plugins/timelines/server/config.ts b/x-pack/plugins/timelines/server/config.ts index 633a95b8f91a73..31be2566118039 100644 --- a/x-pack/plugins/timelines/server/config.ts +++ b/x-pack/plugins/timelines/server/config.ts @@ -1,7 +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. + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. */ import { TypeOf, schema } from '@kbn/config-schema'; diff --git a/x-pack/plugins/timelines/server/index.ts b/x-pack/plugins/timelines/server/index.ts index 32de97be2704a8..65e2b6494c6f47 100644 --- a/x-pack/plugins/timelines/server/index.ts +++ b/x-pack/plugins/timelines/server/index.ts @@ -1,7 +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. + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. */ import { PluginInitializerContext } from '../../../../src/core/server'; diff --git a/x-pack/plugins/timelines/server/plugin.ts b/x-pack/plugins/timelines/server/plugin.ts index 3e330b19b7fdb8..825d42994e0963 100644 --- a/x-pack/plugins/timelines/server/plugin.ts +++ b/x-pack/plugins/timelines/server/plugin.ts @@ -1,3 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + import { PluginInitializerContext, CoreSetup, diff --git a/x-pack/plugins/timelines/server/routes/index.ts b/x-pack/plugins/timelines/server/routes/index.ts index edb10c579b30be..1c651469b795a0 100644 --- a/x-pack/plugins/timelines/server/routes/index.ts +++ b/x-pack/plugins/timelines/server/routes/index.ts @@ -1,3 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + import { IRouter } from '../../../../../src/core/server'; export function defineRoutes(router: IRouter) { diff --git a/x-pack/plugins/timelines/server/types.ts b/x-pack/plugins/timelines/server/types.ts index cb544562b79b4e..5bcc90b48f0b99 100644 --- a/x-pack/plugins/timelines/server/types.ts +++ b/x-pack/plugins/timelines/server/types.ts @@ -1,3 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface TimelinesPluginSetup {} // eslint-disable-next-line @typescript-eslint/no-empty-interface From 417776d9b693f5b0bb8c311b549cff6c40300ebc Mon Sep 17 00:00:00 2001 From: gchaps <33642766+gchaps@users.noreply.github.com> Date: Tue, 13 Apr 2021 07:49:38 -0700 Subject: [PATCH 33/90] [DOCS] Adds concepts section for analysts (#96675) * [DOCS] Adds concepts section for analysts * [DOCS] Minor tweaks to concepts doc * Update docs/concepts/index.asciidoc Co-authored-by: Wylie Conlon * Update docs/concepts/save-query.asciidoc Co-authored-by: Wylie Conlon Co-authored-by: Wylie Conlon --- docs/concepts/images/add-filter-popup.png | Bin 0 -> 31465 bytes docs/concepts/images/global-search.png | Bin 0 -> 46460 bytes docs/concepts/images/refresh-every.png | Bin 0 -> 8560 bytes docs/concepts/images/save-icon.png | Bin 0 -> 841 bytes docs/concepts/images/top-bar.png | Bin 0 -> 64914 bytes docs/concepts/index.asciidoc | 149 ++++++++++++++++++++++ docs/concepts/save-query.asciidoc | 39 ++++++ docs/user/index.asciidoc | 2 + 8 files changed, 190 insertions(+) create mode 100644 docs/concepts/images/add-filter-popup.png create mode 100644 docs/concepts/images/global-search.png create mode 100644 docs/concepts/images/refresh-every.png create mode 100644 docs/concepts/images/save-icon.png create mode 100755 docs/concepts/images/top-bar.png create mode 100644 docs/concepts/index.asciidoc create mode 100644 docs/concepts/save-query.asciidoc diff --git a/docs/concepts/images/add-filter-popup.png b/docs/concepts/images/add-filter-popup.png new file mode 100644 index 0000000000000000000000000000000000000000..f1b5b1ff3f6ca6deef2aa256902642878ddc8a22 GIT binary patch literal 31465 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 literal 0 HcmV?d00001 diff --git a/docs/concepts/images/refresh-every.png b/docs/concepts/images/refresh-every.png new file mode 100644 index 0000000000000000000000000000000000000000..a0930a6c56a653849d6dbc39f95c0cf94d93bb1e GIT binary patch literal 8560 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 P*F#1^Q5-C4;QxOB6{%)W literal 0 HcmV?d00001 diff --git a/docs/concepts/images/save-icon.png b/docs/concepts/images/save-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..959c7ef8e1bb977c49e667902392f8d02ae1c910 GIT binary patch literal 841 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%0AO;aZK=^_BVZzDJ7e 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 NLR40yOi1VZ{{aydX3zit literal 0 HcmV?d00001 diff --git a/docs/concepts/index.asciidoc b/docs/concepts/index.asciidoc new file mode 100644 index 00000000000000..70b8a5265ce8ad --- /dev/null +++ b/docs/concepts/index.asciidoc @@ -0,0 +1,149 @@ +[[kibana-concepts-analysts]] +== {kib} concepts for analysts +**_Learn the shared concepts for analyzing and visualizing your data_** + +As an analyst, you will use a combination of {kib} apps to analyze and +visualize your data. {kib} contains both general-purpose apps and apps for the +https://www.elastic.co/guide/en/enterprise-search/current/index.html[*Enterprise Search*], +{observability-guide}/observability-introduction.html[*Elastic Observability*], +and {security-guide}/es-overview.html[*Elastic Security*] solutions. +These apps share a common set of concepts. + +[float] +=== Three things to know about {es} + +You don't need to know everything about {es} to use {kib}, but the most important concepts follow: + +* *{es} makes JSON documents searchable and aggregatable.* The documents are +stored in an {ref}/documents-indices.html[index] or {ref}/data-streams.html[data stream], which represent one type of data. + +* **_Searchable_ means that you can filter the documents for conditions.** +For example, you can filter for data "within the last 7 days" or data that "contains the word {kib}". +{kib} provides many ways for you to construct filters, which are also called queries or search terms. + +* **_Aggregatable_ means that you can extract summaries from matching documents.** +The simplest aggregation is *count*, and it is frequently used in combination +with the *date histogram*, to see count over time. The *terms* aggregation shows the most frequent values. + +[float] +=== Finding your apps and objects + +{kib} offers a <> on every page that you can use to find any app or saved object. +Open the search bar using the keyboard shortcut Ctrl+/ on Windows and Linux, Command+/ on MacOS. + +[role="screenshot"] +image:concepts/images/global-search.png["Global search showing matches to apps and saved objects for the word visualize"] + +[float] +=== Accessing data with index patterns + +{kib} requires an index pattern to tell it which {es} data you want to access, +and whether the data is time-based. An index pattern can point to one or more {es} +data streams, indices, or index aliases by name. +For example, `logs-elasticsearch-prod-*` is an index pattern, +and it is time-based with a time field of `@timestamp`. The time field is not editable. + +Index patterns are typically created by an administrator when sending data to {es}. +You can <> in *Stack Management*, or by using a script +that accesses the {kib} API. + +{kib} uses the index pattern to show you a list of fields, such as +`event.duration`. You can customize the display name and format for each field. +For example, you can tell Kibana to display `event.duration` in seconds. +{kib} has <> for strings, +dates, geopoints, +and numbers. + +[float] +=== Searching your data + +{kib} provides you several ways to build search queries, +which will reduce the number of document matches that you get from {es}. +Each app in {kib} provides a time filter, and most apps also include semi-structured search and extra filters. + +[role="screenshot"] +image:concepts/images/top-bar.png["Time filter, semi-structured search, and filters in a {kib} app"] + +If you frequently use any of the search options, you can click the +save icon +image:concepts/images/save-icon.png["save icon"] next to the +semi-structured search to save or load a previously saved query. +The saved query will always contain the semi-structured search query, +and can optionally contain the time filter and extra filters. + +[float] +==== Time filter + +The <> limits the time range of data displayed. +In most cases, the time filter applies to the time field in the index pattern, +but some apps allow you to use a different time field. + +Using the time filter, you can configure a refresh rate to periodically +resubmit your searches. You can also click *Refresh* to resubmit the search. +This might be useful if you use {kib} to monitor the underlying data. + +[role="screenshot"] +image:concepts/images/refresh-every.png["section of time filter where you can configure a refresh rate"] + + +[float] +==== Semi-structured search + +Combine free text search with field-based search using the Kibana Query Language (KQL). +Type a search term to match across all fields, or start typing a field name to +get suggestions for field names and operators that you can use to build a structured query. +The semi-structured search will filter documents for matches, and only return matching documents. + +Following are some example KQL queries. For more detailed examples, refer to <>. + +[cols=2*] +|=== +| Exact phrase query +| `http.response.body.content.text:"quick brown fox"` + +| Terms query +| http.response.status_code:400 401 404 + +| Boolean query +| `response:200 or extension:php` + +| Range query +| `account_number >= 100 and items_sold <= 200` + +| Wildcard query +| `machine.os:win*` +|=== + +[float] +==== Additional filters with AND + +Structured filters are a more interactive way to create {es} queries, +and are commonly used when building dashboards that are shared by multiple analysts. +Each filter can be disabled, inverted, or pinned across all apps. +The structured filters are the only way to use the {es} Query DSL in JSON form, +or to target a specific index pattern for filtering. Each of the structured +filters is combined with AND logic on the rest of the query. + +[role="screenshot"] +image:concepts/images/add-filter-popup.png["Add filter popup"] + +[float] +=== Saving objects +{kib} lets you save objects for your own future use or for sharing with others. +Each <> type has different abilities. For example, you can save +your search queries made with *Discover*, which lets you: + +* Share a link to your search +* Download the full search results in CSV form +* Start an aggregated visualization using the same search query +* Embed the *Discover* search results into a dashboard +* Embed the *Discover* search results into a Canvas workpad + +For organization, every saved object can have a name, <>, and type. +Use the global search to quickly open a saved object. + +[float] +=== What's next? + +* Try the {kib} <>, which shows you how to put these concepts into action. +* Go to <> for instructions on searching your data. diff --git a/docs/concepts/save-query.asciidoc b/docs/concepts/save-query.asciidoc new file mode 100644 index 00000000000000..4f049d121bbef5 --- /dev/null +++ b/docs/concepts/save-query.asciidoc @@ -0,0 +1,39 @@ +[[save-load-delete-query]] +== Save a query +A saved query is a collection of query text and filters that you can +reuse in any app with a query bar, like <> and <>. Save a query when you want to: + +* Retrieve results from the same query at a later time without having to reenter the query text, add the filters or set the time filter +* View the results of the same query in multiple apps +* Share your query + +Saved queries don't include information specific to *Discover*, +such as the currently selected columns in the document table, the sort order, and the index pattern. +To save your current view of *Discover* for later retrieval and reuse, +create a <> instead. + +NOTE:: + +If you have insufficient privileges to save queries, the *Save current query* +button isn't visible in the saved query management popover. +For more information, see <> + +. Click *#* in the query bar. +. In the popover, click *Save current query*. ++ +[role="screenshot"] +image::discover/images/saved-query-management-component-all-privileges.png["Example of the saved query management popover with a list of saved queries with write access",width="80%"] ++ +. Enter a name, a description, and then select the filter options. +By default, filters are automatically included, but the time filter is not. ++ +[role="screenshot"] +image::discover/images/saved-query-save-form-default-filters.png["Example of the saved query management save form with the filters option included and the time filter option excluded",width="80%"] +. Click *Save*. +. To load a saved query into *Discover* or *Dashboard*, open the *Saved search* popover, and select the query. +. To manage your saved queries, use these actions in the popover: ++ +* Save as new: Save changes to the current query. +* Clear. Clear a query that is currently loaded in an app. +* Delete. You can’t recover a deleted query. +. To import and export saved queries, go to <>. diff --git a/docs/user/index.asciidoc b/docs/user/index.asciidoc index d7e15258bf29bb..81ded1e54d8fdb 100644 --- a/docs/user/index.asciidoc +++ b/docs/user/index.asciidoc @@ -2,6 +2,8 @@ include::introduction.asciidoc[] include::whats-new.asciidoc[] +include::{kib-repo-dir}/concepts/index.asciidoc[] + include::{kib-repo-dir}/getting-started/quick-start-guide.asciidoc[] include::setup.asciidoc[] From f67f0e80e73d95c72701be5186d14428843951b9 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Tue, 13 Apr 2021 08:03:09 -0700 Subject: [PATCH 34/90] Reporting: Fix _index and _id columns in CSV export (#96097) * Reporting: Fix _index and _id columns in CSV export * optimize - cache _columns and run getColumns once per execution * Update x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts Co-authored-by: Michael Dokolin * feedback * fix typescripts * fix plugin list test * fix plugin list * take away the export interface to test CI build stats Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Michael Dokolin --- .../helpers/get_sharing_data.test.ts | 69 ++++++--- .../application/helpers/get_sharing_data.ts | 17 +-- .../get_csv_panel_action.test.ts | 65 +++++++-- .../panel_actions/get_csv_panel_action.tsx | 28 ++-- .../register_csv_reporting.tsx | 14 +- .../register_pdf_png_reporting.tsx | 14 +- .../__snapshots__/generate_csv.test.ts.snap | 24 +++- .../generate_csv/generate_csv.test.ts | 136 +++++++++++++++++- .../generate_csv/generate_csv.ts | 69 +++++---- .../export_types/csv_searchsource/types.d.ts | 6 +- .../csv_searchsource_immediate/types.d.ts | 5 +- .../routes/csv_searchsource_immediate.ts | 1 + .../apps/dashboard/reporting/download_csv.ts | 3 +- .../csv_searchsource_immediate.ts | 9 +- 14 files changed, 342 insertions(+), 118 deletions(-) diff --git a/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts b/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts index ebb1946b524cd0..6a51c085ebbc98 100644 --- a/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts +++ b/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts @@ -6,13 +6,12 @@ * Side Public License, v 1. */ -import { Capabilities } from 'kibana/public'; -import { getSharingData, showPublicUrlSwitch } from './get_sharing_data'; -import { IUiSettingsClient } from 'kibana/public'; +import { Capabilities, IUiSettingsClient } from 'kibana/public'; +import { IndexPattern } from 'src/plugins/data/public'; import { createSearchSourceMock } from '../../../../data/common/search/search_source/mocks'; -import { indexPatternMock } from '../../__mocks__/index_pattern'; import { DOC_HIDE_TIME_COLUMN_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../common'; -import { IndexPattern } from 'src/plugins/data/public'; +import { indexPatternMock } from '../../__mocks__/index_pattern'; +import { getSharingData, showPublicUrlSwitch } from './get_sharing_data'; describe('getSharingData', () => { let mockConfig: IUiSettingsClient; @@ -36,6 +35,32 @@ describe('getSharingData', () => { const result = await getSharingData(searchSourceMock, { columns: [] }, mockConfig); expect(result).toMatchInlineSnapshot(` Object { + "columns": Array [], + "searchSource": Object { + "index": "the-index-pattern-id", + "sort": Array [ + Object { + "_score": "desc", + }, + ], + }, + } + `); + }); + + test('returns valid data for sharing when columns are selected', async () => { + const searchSourceMock = createSearchSourceMock({ index: indexPatternMock }); + const result = await getSharingData( + searchSourceMock, + { columns: ['column_a', 'column_b'] }, + mockConfig + ); + expect(result).toMatchInlineSnapshot(` + Object { + "columns": Array [ + "column_a", + "column_b", + ], "searchSource": Object { "index": "the-index-pattern-id", "sort": Array [ @@ -69,16 +94,16 @@ describe('getSharingData', () => { ); expect(result).toMatchInlineSnapshot(` Object { + "columns": Array [ + "cool-timefield", + "cool-field-1", + "cool-field-2", + "cool-field-3", + "cool-field-4", + "cool-field-5", + "cool-field-6", + ], "searchSource": Object { - "fields": Array [ - "cool-timefield", - "cool-field-1", - "cool-field-2", - "cool-field-3", - "cool-field-4", - "cool-field-5", - "cool-field-6", - ], "index": "the-index-pattern-id", "sort": Array [ Object { @@ -120,15 +145,15 @@ describe('getSharingData', () => { ); expect(result).toMatchInlineSnapshot(` Object { + "columns": Array [ + "cool-field-1", + "cool-field-2", + "cool-field-3", + "cool-field-4", + "cool-field-5", + "cool-field-6", + ], "searchSource": Object { - "fields": Array [ - "cool-field-1", - "cool-field-2", - "cool-field-3", - "cool-field-4", - "cool-field-5", - "cool-field-6", - ], "index": "the-index-pattern-id", "sort": Array [ Object { diff --git a/src/plugins/discover/public/application/helpers/get_sharing_data.ts b/src/plugins/discover/public/application/helpers/get_sharing_data.ts index f0e07ccc38deb3..47be4b80371522 100644 --- a/src/plugins/discover/public/application/helpers/get_sharing_data.ts +++ b/src/plugins/discover/public/application/helpers/get_sharing_data.ts @@ -7,11 +7,11 @@ */ import type { Capabilities, IUiSettingsClient } from 'kibana/public'; -import { DOC_HIDE_TIME_COLUMN_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../common'; -import { getSortForSearchSource } from '../angular/doc_table'; import { ISearchSource } from '../../../../data/common'; -import { AppState } from '../angular/discover_state'; +import { DOC_HIDE_TIME_COLUMN_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../common'; import type { SavedSearch, SortOrder } from '../../saved_searches/types'; +import { AppState } from '../angular/discover_state'; +import { getSortForSearchSource } from '../angular/doc_table'; /** * Preparing data to share the current state as link or CSV/Report @@ -23,10 +23,6 @@ export async function getSharingData( ) { const searchSource = currentSearchSource.createCopy(); const index = searchSource.getField('index')!; - const fields = { - fields: searchSource.getField('fields'), - fieldsFromSource: searchSource.getField('fieldsFromSource'), - }; searchSource.setField( 'sort', @@ -37,7 +33,7 @@ export async function getSharingData( searchSource.removeField('aggs'); searchSource.removeField('size'); - // fields get re-set to match the saved search columns + // Columns that the user has selected in the saved search let columns = state.columns || []; if (columns && columns.length > 0) { @@ -50,14 +46,11 @@ export async function getSharingData( if (timeFieldName && !columns.includes(timeFieldName)) { columns = [timeFieldName, ...columns]; } - - // if columns were selected in the saved search, use them for the searchSource's fields - const fieldsKey = fields.fieldsFromSource ? 'fieldsFromSource' : 'fields'; - searchSource.setField(fieldsKey, columns); } return { searchSource: searchSource.getSerializedFields(true), + columns, }; } diff --git a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts index 4e1b9ccd2642f8..06d626a4c40442 100644 --- a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts +++ b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts @@ -16,6 +16,7 @@ describe('GetCsvReportPanelAction', () => { let core: any; let context: any; let mockLicense$: any; + let mockSearchSource: any; beforeAll(() => { if (typeof window.URL.revokeObjectURL === 'undefined') { @@ -49,22 +50,19 @@ describe('GetCsvReportPanelAction', () => { }, } as any; + mockSearchSource = { + createCopy: () => mockSearchSource, + removeField: jest.fn(), + setField: jest.fn(), + getField: jest.fn(), + getSerializedFields: jest.fn().mockImplementation(() => ({})), + }; + context = { embeddable: { type: 'search', getSavedSearch: () => { - const searchSource = { - createCopy: () => searchSource, - removeField: jest.fn(), - setField: jest.fn(), - getField: jest.fn().mockImplementation((key: string) => { - if (key === 'index') { - return 'my-test-index-*'; - } - }), - getSerializedFields: jest.fn().mockImplementation(() => ({})), - }; - return { searchSource }; + return { searchSource: mockSearchSource }; }, getTitle: () => `The Dude`, getInspectorAdapters: () => null, @@ -79,6 +77,49 @@ describe('GetCsvReportPanelAction', () => { } as any; }); + it('translates empty embeddable context into job params', async () => { + const panel = new GetCsvReportPanelAction(core, mockLicense$()); + + await panel.execute(context); + + expect(core.http.post).toHaveBeenCalledWith( + '/api/reporting/v1/generate/immediate/csv_searchsource', + { + body: '{"searchSource":{},"columns":[],"browserTimezone":"America/New_York"}', + } + ); + }); + + it('translates embeddable context into job params', async () => { + // setup + mockSearchSource = { + createCopy: () => mockSearchSource, + removeField: jest.fn(), + setField: jest.fn(), + getField: jest.fn(), + getSerializedFields: jest.fn().mockImplementation(() => ({ testData: 'testDataValue' })), + }; + context.embeddable.getSavedSearch = () => { + return { + searchSource: mockSearchSource, + columns: ['column_a', 'column_b'], + }; + }; + + const panel = new GetCsvReportPanelAction(core, mockLicense$()); + + // test + await panel.execute(context); + + expect(core.http.post).toHaveBeenCalledWith( + '/api/reporting/v1/generate/immediate/csv_searchsource', + { + body: + '{"searchSource":{"testData":"testDataValue"},"columns":["column_a","column_b"],"browserTimezone":"America/New_York"}', + } + ); + }); + it('allows downloading for valid licenses', async () => { const panel = new GetCsvReportPanelAction(core, mockLicense$()); diff --git a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx index d440edc3f3fe9f..95d193880975c8 100644 --- a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx +++ b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx @@ -7,21 +7,19 @@ import { i18n } from '@kbn/i18n'; import moment from 'moment-timezone'; -import { CoreSetup } from 'src/core/public'; +import type { CoreSetup } from 'src/core/public'; +import type { ISearchEmbeddable, SavedSearch } from '../../../../../src/plugins/discover/public'; import { loadSharingDataHelpers, - ISearchEmbeddable, - SavedSearch, SEARCH_EMBEDDABLE_TYPE, } from '../../../../../src/plugins/discover/public'; -import { IEmbeddable, ViewMode } from '../../../../../src/plugins/embeddable/public'; -import { - IncompatibleActionError, - UiActionsActionDefinition as ActionDefinition, -} from '../../../../../src/plugins/ui_actions/public'; -import { LicensingPluginSetup } from '../../../licensing/public'; +import type { IEmbeddable } from '../../../../../src/plugins/embeddable/public'; +import { ViewMode } from '../../../../../src/plugins/embeddable/public'; +import type { UiActionsActionDefinition as ActionDefinition } from '../../../../../src/plugins/ui_actions/public'; +import { IncompatibleActionError } from '../../../../../src/plugins/ui_actions/public'; +import type { LicensingPluginSetup } from '../../../licensing/public'; import { API_GENERATE_IMMEDIATE, CSV_REPORTING_ACTION } from '../../common/constants'; -import { JobParamsDownloadCSV } from '../../server/export_types/csv_searchsource_immediate/types'; +import type { JobParamsDownloadCSV } from '../../server/export_types/csv_searchsource_immediate/types'; import { checkLicense } from '../lib/license_check'; function isSavedSearchEmbeddable( @@ -64,14 +62,11 @@ export class GetCsvReportPanelAction implements ActionDefinition public async getSearchSource(savedSearch: SavedSearch, embeddable: ISearchEmbeddable) { const { getSharingData } = await loadSharingDataHelpers(); - const searchSource = savedSearch.searchSource.createCopy(); - const { searchSource: serializedSearchSource } = await getSharingData( - searchSource, + return await getSharingData( + savedSearch.searchSource, savedSearch, // TODO: get unsaved state (using embeddale.searchScope): https://github.com/elastic/kibana/issues/43977 this.core.uiSettings ); - - return serializedSearchSource; } public isCompatible = async (context: ActionContext) => { @@ -96,12 +91,13 @@ export class GetCsvReportPanelAction implements ActionDefinition } const savedSearch = embeddable.getSavedSearch(); - const searchSource = await this.getSearchSource(savedSearch, embeddable); + const { columns, searchSource } = await this.getSearchSource(savedSearch, embeddable); const kibanaTimezone = this.core.uiSettings.get('dateFormat:tz'); const browserTimezone = kibanaTimezone === 'Browser' ? moment.tz.guess() : kibanaTimezone; const immediateJobParams: JobParamsDownloadCSV = { searchSource, + columns, browserTimezone, title: savedSearch.title, }; diff --git a/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx b/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx index 97433f7a4f0c16..8995ef4739b094 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx @@ -8,14 +8,15 @@ import { i18n } from '@kbn/i18n'; import moment from 'moment-timezone'; import React from 'react'; -import { IUiSettingsClient, ToastsSetup } from 'src/core/public'; -import { ShareContext } from '../../../../../src/plugins/share/public'; -import { LicensingPluginSetup } from '../../../licensing/public'; +import type { IUiSettingsClient, ToastsSetup } from 'src/core/public'; +import type { SearchSourceFields } from 'src/plugins/data/common'; +import type { ShareContext } from '../../../../../src/plugins/share/public'; +import type { LicensingPluginSetup } from '../../../licensing/public'; import { CSV_JOB_TYPE } from '../../common/constants'; -import { JobParamsCSV } from '../../server/export_types/csv_searchsource/types'; +import type { JobParamsCSV } from '../../server/export_types/csv_searchsource/types'; import { ReportingPanelContent } from '../components/reporting_panel_content_lazy'; import { checkLicense } from '../lib/license_check'; -import { ReportingAPIClient } from '../lib/reporting_api_client'; +import type { ReportingAPIClient } from '../lib/reporting_api_client'; interface ReportingProvider { apiClient: ReportingAPIClient; @@ -65,7 +66,8 @@ export const csvReportingProvider = ({ browserTimezone, title: sharingData.title as string, objectType, - searchSource: sharingData.searchSource, + searchSource: sharingData.searchSource as SearchSourceFields, + columns: sharingData.columns as string[] | undefined, }; const getJobParams = () => jobParams; diff --git a/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx b/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx index 87011cc9185877..00ba167c50ae60 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx @@ -8,15 +8,15 @@ import { i18n } from '@kbn/i18n'; import moment from 'moment-timezone'; import React from 'react'; -import { IUiSettingsClient, ToastsSetup } from 'src/core/public'; -import { ShareContext } from '../../../../../src/plugins/share/public'; -import { LicensingPluginSetup } from '../../../licensing/public'; -import { LayoutParams } from '../../common/types'; -import { JobParamsPNG } from '../../server/export_types/png/types'; -import { JobParamsPDF } from '../../server/export_types/printable_pdf/types'; +import type { IUiSettingsClient, ToastsSetup } from 'src/core/public'; +import type { ShareContext } from '../../../../../src/plugins/share/public'; +import type { LicensingPluginSetup } from '../../../licensing/public'; +import type { LayoutParams } from '../../common/types'; +import type { JobParamsPNG } from '../../server/export_types/png/types'; +import type { JobParamsPDF } from '../../server/export_types/printable_pdf/types'; import { ScreenCapturePanelContent } from '../components/screen_capture_panel_content_lazy'; import { checkLicense } from '../lib/license_check'; -import { ReportingAPIClient } from '../lib/reporting_api_client'; +import type { ReportingAPIClient } from '../lib/reporting_api_client'; interface ReportingPDFPNGProvider { apiClient: ReportingAPIClient; diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/__snapshots__/generate_csv.test.ts.snap b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/__snapshots__/generate_csv.test.ts.snap index 62c9ecff830ffd..789b68a25ac423 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/__snapshots__/generate_csv.test.ts.snap +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/__snapshots__/generate_csv.test.ts.snap @@ -1,18 +1,36 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`fields cells can be multi-value 1`] = ` +exports[`fields from job.columns (7.13+ generated) cells can be multi-value 1`] = ` +"product,category +coconut,\\"cool, rad\\" +" +`; + +exports[`fields from job.columns (7.13+ generated) columns can be top-level fields such as _id and _index 1`] = ` +"\\"_id\\",\\"_index\\",product,category +\\"my-cool-id\\",\\"my-cool-index\\",coconut,\\"cool, rad\\" +" +`; + +exports[`fields from job.columns (7.13+ generated) empty columns defaults to using searchSource.getFields() 1`] = ` +"product +coconut +" +`; + +exports[`fields from job.searchSource.getFields() (7.12 generated) cells can be multi-value 1`] = ` "\\"_id\\",sku \\"my-cool-id\\",\\"This is a cool SKU., This is also a cool SKU.\\" " `; -exports[`fields provides top-level underscored fields as columns 1`] = ` +exports[`fields from job.searchSource.getFields() (7.12 generated) provides top-level underscored fields as columns 1`] = ` "\\"_id\\",\\"_index\\",date,message \\"my-cool-id\\",\\"my-cool-index\\",\\"2020-12-31T00:14:28.000Z\\",\\"it's nice to see you\\" " `; -exports[`fields sorts the fields when they are to be used as table column names 1`] = ` +exports[`fields from job.searchSource.getFields() (7.12 generated) sorts the fields when they are to be used as table column names 1`] = ` "\\"_id\\",\\"_index\\",\\"_score\\",\\"_type\\",date,\\"message_t\\",\\"message_u\\",\\"message_v\\",\\"message_w\\",\\"message_x\\",\\"message_y\\",\\"message_z\\" \\"my-cool-id\\",\\"my-cool-index\\",\\"'-\\",\\"'-\\",\\"2020-12-31T00:14:28.000Z\\",\\"test field T\\",\\"test field U\\",\\"test field V\\",\\"test field W\\",\\"test field X\\",\\"test field Y\\",\\"test field Z\\" " diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts index 0193eaaff2c8d9..8694eddce79677 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts @@ -326,7 +326,7 @@ it('uses the scrollId to page all the data', async () => { expect(csvResult.content).toMatchSnapshot(); }); -describe('fields', () => { +describe('fields from job.searchSource.getFields() (7.12 generated)', () => { it('cells can be multi-value', async () => { searchSourceMock.getField = jest.fn().mockImplementation((key: string) => { if (key === 'fields') { @@ -497,6 +497,140 @@ describe('fields', () => { }); }); +describe('fields from job.columns (7.13+ generated)', () => { + it('cells can be multi-value', async () => { + mockDataClient.search = jest.fn().mockImplementation(() => + Rx.of({ + rawResponse: { + hits: { + hits: [ + { + _id: 'my-cool-id', + _index: 'my-cool-index', + _version: 4, + fields: { + product: 'coconut', + category: [`cool`, `rad`], + }, + }, + ], + total: 1, + }, + }, + }) + ); + + const generateCsv = new CsvGenerator( + createMockJob({ searchSource: {}, columns: ['product', 'category'] }), + mockConfig, + { + es: mockEsClient, + data: mockDataClient, + uiSettings: uiSettingsClient, + }, + { + searchSourceStart: mockSearchSourceService, + fieldFormatsRegistry: mockFieldFormatsRegistry, + }, + new CancellationToken(), + logger + ); + const csvResult = await generateCsv.generateData(); + + expect(csvResult.content).toMatchSnapshot(); + }); + + it('columns can be top-level fields such as _id and _index', async () => { + mockDataClient.search = jest.fn().mockImplementation(() => + Rx.of({ + rawResponse: { + hits: { + hits: [ + { + _id: 'my-cool-id', + _index: 'my-cool-index', + _version: 4, + fields: { + product: 'coconut', + category: [`cool`, `rad`], + }, + }, + ], + total: 1, + }, + }, + }) + ); + + const generateCsv = new CsvGenerator( + createMockJob({ searchSource: {}, columns: ['_id', '_index', 'product', 'category'] }), + mockConfig, + { + es: mockEsClient, + data: mockDataClient, + uiSettings: uiSettingsClient, + }, + { + searchSourceStart: mockSearchSourceService, + fieldFormatsRegistry: mockFieldFormatsRegistry, + }, + new CancellationToken(), + logger + ); + const csvResult = await generateCsv.generateData(); + + expect(csvResult.content).toMatchSnapshot(); + }); + + it('empty columns defaults to using searchSource.getFields()', async () => { + searchSourceMock.getField = jest.fn().mockImplementation((key: string) => { + if (key === 'fields') { + return ['product']; + } + return mockSearchSourceGetFieldDefault(key); + }); + mockDataClient.search = jest.fn().mockImplementation(() => + Rx.of({ + rawResponse: { + hits: { + hits: [ + { + _id: 'my-cool-id', + _index: 'my-cool-index', + _version: 4, + fields: { + product: 'coconut', + category: [`cool`, `rad`], + }, + }, + ], + total: 1, + }, + }, + }) + ); + + const generateCsv = new CsvGenerator( + createMockJob({ searchSource: {}, columns: [] }), + mockConfig, + { + es: mockEsClient, + data: mockDataClient, + uiSettings: uiSettingsClient, + }, + { + searchSourceStart: mockSearchSourceService, + fieldFormatsRegistry: mockFieldFormatsRegistry, + }, + new CancellationToken(), + logger + ); + const csvResult = await generateCsv.generateData(); + + expect(csvResult.content).toMatchSnapshot(); + }); +}); + describe('formulas', () => { const TEST_FORMULA = '=SUM(A1:A2)'; diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts index 01959ed08036da..7517396961c004 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts @@ -20,6 +20,7 @@ import { ISearchSource, ISearchStartSearchSource, SearchFieldValue, + SearchSourceFields, tabifyDocs, } from '../../../../../../../src/plugins/data/common'; import { KbnServerError } from '../../../../../../../src/plugins/kibana_utils/server'; @@ -60,7 +61,8 @@ function isPlainStringArray( } export class CsvGenerator { - private _formatters: Record | null = null; + private _columns?: string[]; + private _formatters?: Record; private csvContainsFormulas = false; private maxSizeReached = false; private csvRowCount = 0; @@ -135,27 +137,36 @@ export class CsvGenerator { }; } - // use fields/fieldsFromSource from the searchSource to get the ordering of columns - // otherwise use the table columns as they are - private getFields(searchSource: ISearchSource, table: Datatable): string[] { - const fieldValues: Record = { - fields: searchSource.getField('fields'), - fieldsFromSource: searchSource.getField('fieldsFromSource'), - }; - const fieldSource = fieldValues.fieldsFromSource ? 'fieldsFromSource' : 'fields'; - this.logger.debug(`Getting search source fields from: '${fieldSource}'`); - - const fields = fieldValues[fieldSource]; - // Check if field name values are string[] and if the fields are user-defined - if (isPlainStringArray(fields)) { - return fields; + private getColumns(searchSource: ISearchSource, table: Datatable) { + if (this._columns != null) { + return this._columns; } - // Default to using the table column IDs as the fields - const columnIds = table.columns.map((c) => c.id); - // Fields in the API response don't come sorted - they need to be sorted client-side - columnIds.sort(); - return columnIds; + // if columns is not provided in job params, + // default to use fields/fieldsFromSource from the searchSource to get the ordering of columns + const getFromSearchSource = (): string[] => { + const fieldValues: Pick = { + fields: searchSource.getField('fields'), + fieldsFromSource: searchSource.getField('fieldsFromSource'), + }; + const fieldSource = fieldValues.fieldsFromSource ? 'fieldsFromSource' : 'fields'; + this.logger.debug(`Getting columns from '${fieldSource}' in search source.`); + + const fields = fieldValues[fieldSource]; + // Check if field name values are string[] and if the fields are user-defined + if (isPlainStringArray(fields)) { + return fields; + } + + // Default to using the table column IDs as the fields + const columnIds = table.columns.map((c) => c.id); + // Fields in the API response don't come sorted - they need to be sorted client-side + columnIds.sort(); + return columnIds; + }; + this._columns = this.job.columns?.length ? this.job.columns : getFromSearchSource(); + + return this._columns; } private formatCellValues(formatters: Record) { @@ -202,16 +213,16 @@ export class CsvGenerator { } /* - * Use the list of fields to generate the header row + * Use the list of columns to generate the header row */ private generateHeader( - fields: string[], + columns: string[], table: Datatable, builder: MaxSizeStringBuilder, settings: CsvExportSettings ) { this.logger.debug(`Building CSV header row...`); - const header = fields.map(this.escapeValues(settings)).join(settings.separator) + '\n'; + const header = columns.map(this.escapeValues(settings)).join(settings.separator) + '\n'; if (!builder.tryAppend(header)) { return { @@ -227,7 +238,7 @@ export class CsvGenerator { * Format a Datatable into rows of CSV content */ private generateRows( - fields: string[], + columns: string[], table: Datatable, builder: MaxSizeStringBuilder, formatters: Record, @@ -240,7 +251,7 @@ export class CsvGenerator { } const row = - fields + columns .map((f) => ({ column: f, data: dataTableRow[f] })) .map(this.formatCellValues(formatters)) .map(this.escapeValues(settings)) @@ -338,11 +349,13 @@ export class CsvGenerator { break; } - const fields = this.getFields(searchSource, table); + // If columns exists in the job params, use it to order the CSV columns + // otherwise, get the ordering from the searchSource's fields / fieldsFromSource + const columns = this.getColumns(searchSource, table); if (first) { first = false; - this.generateHeader(fields, table, builder, settings); + this.generateHeader(columns, table, builder, settings); } if (table.rows.length < 1) { @@ -350,7 +363,7 @@ export class CsvGenerator { } const formatters = this.getFormatters(table); - this.generateRows(fields, table, builder, formatters, settings); + this.generateRows(columns, table, builder, formatters, settings); // update iterator currentRecord += table.rows.length; diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/types.d.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/types.d.ts index f0ad4e00ebd5cc..d2a9e2b5bf783c 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/types.d.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/types.d.ts @@ -5,13 +5,15 @@ * 2.0. */ -import { BaseParams, BasePayload } from '../../types'; +import type { SearchSourceFields } from 'src/plugins/data/common'; +import type { BaseParams, BasePayload } from '../../types'; export type RawValue = string | object | null | undefined; interface BaseParamsCSV { browserTimezone: string; - searchSource: any; + searchSource: SearchSourceFields; + columns?: string[]; } export type JobParamsCSV = BaseParamsCSV & BaseParams; diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource_immediate/types.d.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource_immediate/types.d.ts index 276016dd612332..cb1dd659ee2c21 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource_immediate/types.d.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource_immediate/types.d.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { TimeRangeParams } from '../common'; +import type { SearchSourceFields } from 'src/plugins/data/common'; export interface FakeRequest { headers: Record; @@ -14,7 +14,8 @@ export interface FakeRequest { export interface JobParamsDownloadCSV { browserTimezone: string; title: string; - searchSource: any; + searchSource: SearchSourceFields; + columns?: string[]; } export interface SavedObjectServiceError { diff --git a/x-pack/plugins/reporting/server/routes/csv_searchsource_immediate.ts b/x-pack/plugins/reporting/server/routes/csv_searchsource_immediate.ts index 55092b5236ce66..5d2b77c082ca56 100644 --- a/x-pack/plugins/reporting/server/routes/csv_searchsource_immediate.ts +++ b/x-pack/plugins/reporting/server/routes/csv_searchsource_immediate.ts @@ -44,6 +44,7 @@ export function registerGenerateCsvFromSavedObjectImmediate( path: `${API_BASE_GENERATE_V1}/immediate/csv_searchsource`, validate: { body: schema.object({ + columns: schema.maybe(schema.arrayOf(schema.string())), searchSource: schema.object({}, { unknowns: 'allow' }), browserTimezone: schema.string({ defaultValue: 'UTC' }), title: schema.string(), diff --git a/x-pack/test/functional/apps/dashboard/reporting/download_csv.ts b/x-pack/test/functional/apps/dashboard/reporting/download_csv.ts index c437cfaa8f5dc7..d4a909f6a04741 100644 --- a/x-pack/test/functional/apps/dashboard/reporting/download_csv.ts +++ b/x-pack/test/functional/apps/dashboard/reporting/download_csv.ts @@ -50,8 +50,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.existOrFail('csvDownloadStarted'); // validate toast panel }; - // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/96000 - describe.skip('Download CSV', () => { + describe('Download CSV', () => { before('initialize tests', async () => { log.debug('ReportingPage:initTests'); await browser.setWindowSize(1600, 850); diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/csv_searchsource_immediate.ts b/x-pack/test/reporting_api_integration/reporting_and_security/csv_searchsource_immediate.ts index ebc7badd88f427..f381bc1edd28ed 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/csv_searchsource_immediate.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/csv_searchsource_immediate.ts @@ -10,9 +10,9 @@ import supertest from 'supertest'; import { JobParamsDownloadCSV } from '../../../plugins/reporting/server/export_types/csv_searchsource_immediate/types'; import { FtrProviderContext } from '../ftr_provider_context'; -const getMockJobParams = (obj: Partial): JobParamsDownloadCSV => ({ +const getMockJobParams = (obj: any): JobParamsDownloadCSV => ({ title: `Mock CSV Title`, - ...(obj as any), + ...obj, }); // eslint-disable-next-line import/no-default-export @@ -31,8 +31,7 @@ export default function ({ getService }: FtrProviderContext) { }, }; - // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/96000 - describe.skip('CSV Generation from SearchSource', () => { + describe('CSV Generation from SearchSource', () => { before(async () => { await kibanaServer.uiSettings.update({ 'csv:quoteValues': false, @@ -387,9 +386,9 @@ export default function ({ getService }: FtrProviderContext) { version: true, index: '907bc200-a294-11e9-a900-ef10e0ac769e', sort: [{ date: 'desc' }], - fields: ['date', 'message', '_id', '_index'], filter: [], }, + columns: ['date', 'message', '_id', '_index'], }) ); const { status: resStatus, text: resText, type: resType } = res; From 0260dacfc80757d08d46363aff1367414e334873 Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Tue, 13 Apr 2021 17:15:41 +0200 Subject: [PATCH 35/90] [Graph] Enable partial pasting in drilldowns (#96830) --- .../public/components/settings/url_template_form.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/graph/public/components/settings/url_template_form.tsx b/x-pack/plugins/graph/public/components/settings/url_template_form.tsx index 51dc310ababa2c..e89640ef2dbe21 100644 --- a/x-pack/plugins/graph/public/components/settings/url_template_form.tsx +++ b/x-pack/plugins/graph/public/components/settings/url_template_form.tsx @@ -216,15 +216,18 @@ export function UrlTemplateForm(props: UrlTemplateFormProps) { value={currentTemplate.url} onChange={(e) => { setValue('url', e.target.value); - setAutoformatUrl(false); + if ( + (e.nativeEvent as InputEvent)?.inputType !== 'insertFromPaste' || + !isKibanaUrl(e.target.value) + ) { + setAutoformatUrl(false); + } }} onPaste={(e) => { - e.preventDefault(); const pastedUrl = e.clipboardData.getData('text/plain'); if (isKibanaUrl(pastedUrl)) { setAutoformatUrl(true); } - setValue('url', pastedUrl); }} isInvalid={urlPlaceholderMissing || (touched.url && !currentTemplate.url)} /> From 0e7612dd1af63c38f8a75b50c8566d7375c91278 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Tue, 13 Apr 2021 11:16:32 -0400 Subject: [PATCH 36/90] [Fleet] Fix Fleet API integration tests (#96837) --- x-pack/scripts/functional_tests.js | 3 +-- .../test/fleet_api_integration/apis/agents/reassign.ts | 2 ++ .../test/fleet_api_integration/apis/agents/upgrade.ts | 5 ++++- x-pack/test/fleet_api_integration/apis/agents_setup.ts | 2 +- x-pack/test/fleet_api_integration/apis/epm/list.ts | 2 +- x-pack/test/fleet_api_integration/apis/fleet_setup.ts | 2 +- .../functional/es_archives/fleet/agents/mappings.json | 10 +++++----- .../es_archives/fleet/empty_fleet_server/mappings.json | 10 +++++----- 8 files changed, 20 insertions(+), 16 deletions(-) diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index b7df493a1036ab..1f6fe310bfa7cc 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -74,8 +74,7 @@ const onlyNotInCoverageTests = [ require.resolve('../test/reporting_api_integration/reporting_and_security.config.ts'), require.resolve('../test/reporting_api_integration/reporting_without_security.config.ts'), require.resolve('../test/security_solution_endpoint_api_int/config.ts'), - // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/96515 - // require.resolve('../test/fleet_api_integration/config.ts'), + require.resolve('../test/fleet_api_integration/config.ts'), require.resolve('../test/search_sessions_integration/config.ts'), require.resolve('../test/saved_object_tagging/api_integration/security_and_spaces/config.ts'), require.resolve('../test/saved_object_tagging/api_integration/tagging_api/config.ts'), diff --git a/x-pack/test/fleet_api_integration/apis/agents/reassign.ts b/x-pack/test/fleet_api_integration/apis/agents/reassign.ts index 627cb299f0909d..5737794eefeab0 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/reassign.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/reassign.ts @@ -19,11 +19,13 @@ export default function (providerContext: FtrProviderContext) { await esArchiver.load('fleet/empty_fleet_server'); }); beforeEach(async () => { + await esArchiver.unload('fleet/empty_fleet_server'); await esArchiver.load('fleet/agents'); }); setupFleetAndAgents(providerContext); afterEach(async () => { await esArchiver.unload('fleet/agents'); + await esArchiver.load('fleet/empty_fleet_server'); }); after(async () => { await esArchiver.unload('fleet/empty_fleet_server'); diff --git a/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts b/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts index 41232f73efa5c9..008614f075514a 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts @@ -26,12 +26,15 @@ export default function (providerContext: FtrProviderContext) { describe('fleet upgrade', () => { skipIfNoDockerRegistry(providerContext); before(async () => { - await esArchiver.loadIfNeeded('fleet/agents'); + await esArchiver.load('fleet/agents'); }); setupFleetAndAgents(providerContext); beforeEach(async () => { await esArchiver.load('fleet/agents'); }); + afterEach(async () => { + await esArchiver.unload('fleet/agents'); + }); after(async () => { await esArchiver.unload('fleet/agents'); }); diff --git a/x-pack/test/fleet_api_integration/apis/agents_setup.ts b/x-pack/test/fleet_api_integration/apis/agents_setup.ts index 700a06750d2f47..25b4e16535fdae 100644 --- a/x-pack/test/fleet_api_integration/apis/agents_setup.ts +++ b/x-pack/test/fleet_api_integration/apis/agents_setup.ts @@ -24,7 +24,7 @@ export default function (providerContext: FtrProviderContext) { after(async () => { await esArchiver.unload('empty_kibana'); - await esArchiver.load('fleet/empty_fleet_server'); + await esArchiver.unload('fleet/empty_fleet_server'); }); beforeEach(async () => { diff --git a/x-pack/test/fleet_api_integration/apis/epm/list.ts b/x-pack/test/fleet_api_integration/apis/epm/list.ts index 5a991e52bdba4e..c482f4012d2e5a 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/list.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/list.ts @@ -26,7 +26,7 @@ export default function (providerContext: FtrProviderContext) { }); setupFleetAndAgents(providerContext); after(async () => { - await esArchiver.load('fleet/empty_fleet_server'); + await esArchiver.unload('fleet/empty_fleet_server'); }); describe('list api tests', async () => { diff --git a/x-pack/test/fleet_api_integration/apis/fleet_setup.ts b/x-pack/test/fleet_api_integration/apis/fleet_setup.ts index c9709475d182d9..4c16a4fbd1cfa6 100644 --- a/x-pack/test/fleet_api_integration/apis/fleet_setup.ts +++ b/x-pack/test/fleet_api_integration/apis/fleet_setup.ts @@ -24,7 +24,7 @@ export default function (providerContext: FtrProviderContext) { after(async () => { await esArchiver.unload('empty_kibana'); - await esArchiver.load('fleet/empty_fleet_server'); + await esArchiver.unload('fleet/empty_fleet_server'); }); beforeEach(async () => { try { diff --git a/x-pack/test/functional/es_archives/fleet/agents/mappings.json b/x-pack/test/functional/es_archives/fleet/agents/mappings.json index 5b35fa05a51bfd..72a7368e4d0a82 100644 --- a/x-pack/test/functional/es_archives/fleet/agents/mappings.json +++ b/x-pack/test/functional/es_archives/fleet/agents/mappings.json @@ -3089,7 +3089,7 @@ ".fleet-actions": { } }, - "index": ".fleet-actions_1", + "index": ".fleet-actions-7", "mappings": { "_meta": { "migrationHash": "6527beea5a4a2f33acb599585ed4710442ece7f2" @@ -3136,7 +3136,7 @@ ".fleet-agents": { } }, - "index": ".fleet-agents_1", + "index": ".fleet-agent-7", "mappings": { "_meta": { "migrationHash": "87cab95ac988d78a78d0d66bbf05361b65dcbacf" @@ -3373,7 +3373,7 @@ ".fleet-enrollment-api-keys": { } }, - "index": ".fleet-enrollment-api-keys_1", + "index": ".fleet-enrollment-api-keys-7", "mappings": { "_meta": { "migrationHash": "06bef724726f3bea9f474a09be0a7f7881c28d4a" @@ -3422,7 +3422,7 @@ ".fleet-policies": { } }, - "index": ".fleet-policies_1", + "index": ".fleet-policies-7", "mappings": { "_meta": { "migrationHash": "c2c2a49b19562942fa7c1ff1537e66e751cdb4fa" @@ -3466,7 +3466,7 @@ ".fleet-servers": { } }, - "index": ".fleet-servers_1", + "index": ".fleet-servers-7", "mappings": { "_meta": { "migrationHash": "e2782448c7235ec9af66ca7997e867d715ac379c" diff --git a/x-pack/test/functional/es_archives/fleet/empty_fleet_server/mappings.json b/x-pack/test/functional/es_archives/fleet/empty_fleet_server/mappings.json index 73f090b6103dc4..a04b7a7dc21c7e 100644 --- a/x-pack/test/functional/es_archives/fleet/empty_fleet_server/mappings.json +++ b/x-pack/test/functional/es_archives/fleet/empty_fleet_server/mappings.json @@ -5,7 +5,7 @@ ".fleet-actions": { } }, - "index": ".fleet-actions_1", + "index": ".fleet-actions-7", "mappings": { "_meta": { "migrationHash": "6527beea5a4a2f33acb599585ed4710442ece7f2" @@ -52,7 +52,7 @@ ".fleet-agents": { } }, - "index": ".fleet-agents_1", + "index": ".fleet-agents-7", "mappings": { "_meta": { "migrationHash": "87cab95ac988d78a78d0d66bbf05361b65dcbacf" @@ -289,7 +289,7 @@ ".fleet-enrollment-api-keys": { } }, - "index": ".fleet-enrollment-api-keys_1", + "index": ".fleet-enrollment-api-keys-7", "mappings": { "_meta": { "migrationHash": "06bef724726f3bea9f474a09be0a7f7881c28d4a" @@ -338,7 +338,7 @@ ".fleet-policies": { } }, - "index": ".fleet-policies_1", + "index": ".fleet-policies-7", "mappings": { "_meta": { "migrationHash": "c2c2a49b19562942fa7c1ff1537e66e751cdb4fa" @@ -382,7 +382,7 @@ ".fleet-servers": { } }, - "index": ".fleet-servers_1", + "index": ".fleet-servers-7", "mappings": { "_meta": { "migrationHash": "e2782448c7235ec9af66ca7997e867d715ac379c" From ff6d1d709e83961ec4067ec33f99b7d267179a47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Tue, 13 Apr 2021 17:40:42 +0200 Subject: [PATCH 37/90] `.editorconfig` MDX files should follow the same rules as MD (#96942) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .editorconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.editorconfig b/.editorconfig index 7564b3596f0433..ec8a51f2314bea 100644 --- a/.editorconfig +++ b/.editorconfig @@ -12,6 +12,6 @@ insert_final_newline = true [package.json] insert_final_newline = false -[*.{md,asciidoc}] +[*.{md,mdx,asciidoc}] trim_trailing_whitespace = false insert_final_newline = false From c937fc35e3953437d80042250ad327840022a18d Mon Sep 17 00:00:00 2001 From: Pete Harverson Date: Tue, 13 Apr 2021 16:50:04 +0100 Subject: [PATCH 38/90] [ML] Fix check for too many selected buckets in Anomaly Explorer charts (#96771) --- .../services/anomaly_explorer_charts_service.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts index 70b7632775bf5a..d760ff9455a885 100644 --- a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts +++ b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts @@ -159,12 +159,13 @@ export class AnomalyExplorerChartsService { const halfPoints = Math.ceil(plotPoints / 2); const bounds = timeFilter.getActiveBounds(); const boundsMin = bounds?.min ? bounds.min.valueOf() : undefined; + const boundsMax = bounds?.max ? bounds.max.valueOf() : undefined; let chartRange: ChartRange = { min: boundsMin ? Math.max(midpointMs - halfPoints * minBucketSpanMs, boundsMin) : midpointMs - halfPoints * minBucketSpanMs, - max: bounds?.max - ? Math.min(midpointMs + halfPoints * minBucketSpanMs, bounds.max.valueOf()) + max: boundsMax + ? Math.min(midpointMs + halfPoints * minBucketSpanMs, boundsMax) : midpointMs + halfPoints * minBucketSpanMs, }; @@ -210,15 +211,21 @@ export class AnomalyExplorerChartsService { } // Elasticsearch aggregation returns points at start of bucket, - // so align the min to the length of the longest bucket. + // so align the min to the length of the longest bucket, + // and use the start of the latest selected bucket in the check + // for too many selected buckets, respecting the max bounds set in the view. chartRange.min = Math.floor(chartRange.min / maxBucketSpanMs) * maxBucketSpanMs; if (boundsMin !== undefined && chartRange.min < boundsMin) { chartRange.min = chartRange.min + maxBucketSpanMs; } + const selectedLatestBucketStart = boundsMax + ? Math.floor(Math.min(selectedLatestMs, boundsMax) / maxBucketSpanMs) * maxBucketSpanMs + : Math.floor(selectedLatestMs / maxBucketSpanMs) * maxBucketSpanMs; + if ( - (chartRange.min > selectedEarliestMs || chartRange.max < selectedLatestMs) && - chartRange.max - chartRange.min < selectedLatestMs - selectedEarliestMs + (chartRange.min > selectedEarliestMs || chartRange.max < selectedLatestBucketStart) && + chartRange.max - chartRange.min < selectedLatestBucketStart - selectedEarliestMs ) { tooManyBuckets = true; } From 7edacdade17b758a4bbc685176c69b400d9910f0 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 13 Apr 2021 18:20:44 +0200 Subject: [PATCH 39/90] give test more time (#96955) --- .../public/debounced_component/debounced_component.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/lens/public/debounced_component/debounced_component.test.tsx b/x-pack/plugins/lens/public/debounced_component/debounced_component.test.tsx index 43dcefae66ad51..6beb565be098c1 100644 --- a/x-pack/plugins/lens/public/debounced_component/debounced_component.test.tsx +++ b/x-pack/plugins/lens/public/debounced_component/debounced_component.test.tsx @@ -26,7 +26,7 @@ describe('debouncedComponent', () => { component.setProps({ title: 'yall' }); expect(component.text()).toEqual('there'); await act(async () => { - await new Promise((r) => setTimeout(r, 1)); + await new Promise((r) => setTimeout(r, 10)); }); expect(component.text()).toEqual('yall'); }); From 74d93a2f6d26c6ad01da51a54a3f6c5a83364213 Mon Sep 17 00:00:00 2001 From: Catherine Liu Date: Tue, 13 Apr 2021 09:24:17 -0700 Subject: [PATCH 40/90] [Presentation Util] Shared toolbar component (#94139) --- packages/kbn-optimizer/limits.yml | 2 +- .../dashboard/.storybook/storyshots.test.tsx | 76 ------- src/plugins/dashboard/kibana.json | 3 +- .../application/top_nav/dashboard_top_nav.tsx | 66 +++++- .../panel_toolbar.stories.storyshot | 71 ------ .../top_nav/panel_toolbar/panel_toolbar.tsx | 51 ----- src/plugins/dashboard/tsconfig.json | 3 + .../components/solution_toolbar}/index.ts | 3 +- .../items/add_from_library.tsx | 24 ++ .../solution_toolbar/items/button.scss} | 6 +- .../solution_toolbar/items/button.tsx | 30 +++ .../solution_toolbar/items/index.ts | 14 ++ .../solution_toolbar/items/popover.tsx | 36 +++ .../items/primary_button.tsx} | 14 +- .../items/primary_popover.tsx | 17 ++ .../solution_toolbar/items/quick_group.scss | 5 + .../solution_toolbar/items/quick_group.tsx | 58 +++++ .../solution_toolbar/solution_toolbar.scss | 4 + .../solution_toolbar.stories.tsx | 205 ++++++++++++++++++ .../solution_toolbar/solution_toolbar.tsx | 62 ++++++ .../public/i18n/components.ts | 35 +++ src/plugins/presentation_util/public/index.ts | 10 + src/plugins/presentation_util/tsconfig.json | 9 +- .../visualize_embeddable_factory.tsx | 2 +- src/plugins/visualizations/public/mocks.ts | 2 - src/plugins/visualizations/public/plugin.ts | 4 +- src/plugins/visualizations/tsconfig.json | 1 - .../dashboard/create_and_add_embeddables.ts | 30 +++ .../apps/dashboard/edit_visualizations.js | 5 +- .../functional/page_objects/dashboard_page.ts | 10 + .../translations/translations/ja-JP.json | 3 +- .../translations/translations/zh-CN.json | 3 +- 32 files changed, 626 insertions(+), 238 deletions(-) delete mode 100644 src/plugins/dashboard/.storybook/storyshots.test.tsx delete mode 100644 src/plugins/dashboard/public/application/top_nav/panel_toolbar/__snapshots__/panel_toolbar.stories.storyshot delete mode 100644 src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.tsx rename src/plugins/{dashboard/public/application/top_nav/panel_toolbar => presentation_util/public/components/solution_toolbar}/index.ts (81%) create mode 100644 src/plugins/presentation_util/public/components/solution_toolbar/items/add_from_library.tsx rename src/plugins/{dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.scss => presentation_util/public/components/solution_toolbar/items/button.scss} (73%) create mode 100644 src/plugins/presentation_util/public/components/solution_toolbar/items/button.tsx create mode 100644 src/plugins/presentation_util/public/components/solution_toolbar/items/index.ts create mode 100644 src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx rename src/plugins/{dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.stories.tsx => presentation_util/public/components/solution_toolbar/items/primary_button.tsx} (53%) create mode 100644 src/plugins/presentation_util/public/components/solution_toolbar/items/primary_popover.tsx create mode 100644 src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss create mode 100644 src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx create mode 100644 src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.scss create mode 100644 src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.stories.tsx create mode 100644 src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.tsx create mode 100644 src/plugins/presentation_util/public/i18n/components.ts diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 249183d4b1e316..e114e3e9300168 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -100,7 +100,7 @@ pageLoadAssetSize: watcher: 43598 runtimeFields: 41752 stackAlerts: 29684 - presentationUtil: 28545 + presentationUtil: 49767 spacesOss: 18817 indexPatternFieldEditor: 90489 osquery: 107090 diff --git a/src/plugins/dashboard/.storybook/storyshots.test.tsx b/src/plugins/dashboard/.storybook/storyshots.test.tsx deleted file mode 100644 index 80e8aa795ed400..00000000000000 --- a/src/plugins/dashboard/.storybook/storyshots.test.tsx +++ /dev/null @@ -1,76 +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 - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import fs from 'fs'; -import { ReactChildren } from 'react'; -import path from 'path'; -import moment from 'moment'; -import 'moment-timezone'; -import ReactDOM from 'react-dom'; - -import initStoryshots, { multiSnapshotWithOptions } from '@storybook/addon-storyshots'; -// @ts-ignore -import styleSheetSerializer from 'jest-styled-components/src/styleSheetSerializer'; -import { addSerializer } from 'jest-specific-snapshot'; - -// Set our default timezone to UTC for tests so we can generate predictable snapshots -moment.tz.setDefault('UTC'); - -// Freeze time for the tests for predictable snapshots -const testTime = new Date(Date.UTC(2019, 5, 1)); // June 1 2019 -Date.now = jest.fn(() => testTime.getTime()); - -// Mock React Portal for components that use modals, tooltips, etc -// @ts-expect-error Portal mocks are notoriously difficult to type -ReactDOM.createPortal = jest.fn((element) => element); - -// Mock EUI generated ids to be consistently predictable for snapshots. -jest.mock(`@elastic/eui/lib/components/form/form_row/make_id`, () => () => `generated-id`); - -// Mock react-datepicker dep used by eui to avoid rendering the entire large component -jest.mock('@elastic/eui/packages/react-datepicker', () => { - return { - __esModule: true, - default: 'ReactDatePicker', - }; -}); - -// Mock the EUI HTML ID Generator so elements have a predictable ID in snapshots -jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => { - return { - htmlIdGenerator: () => () => `generated-id`, - }; -}); - -// To be resolved by EUI team. -// https://github.com/elastic/eui/issues/3712 -jest.mock('@elastic/eui/lib/components/overlay_mask/overlay_mask', () => { - return { - EuiOverlayMask: ({ children }: { children: ReactChildren }) => children, - }; -}); - -// @ts-ignore -import { EuiObserver } from '@elastic/eui/test-env/components/observer/observer'; -jest.mock('@elastic/eui/test-env/components/observer/observer'); -EuiObserver.mockImplementation(() => 'EuiObserver'); - -// Some of the code requires that this directory exists, but the tests don't actually require any css to be present -const cssDir = path.resolve(__dirname, '../../../../built_assets/css'); -if (!fs.existsSync(cssDir)) { - fs.mkdirSync(cssDir, { recursive: true }); -} - -addSerializer(styleSheetSerializer); - -// Initialize Storyshots and build the Jest Snapshots -initStoryshots({ - configPath: path.resolve(__dirname, './../.storybook'), - framework: 'react', - test: multiSnapshotWithOptions({}), -}); diff --git a/src/plugins/dashboard/kibana.json b/src/plugins/dashboard/kibana.json index 32507cbc5e5f47..41335069461fae 100644 --- a/src/plugins/dashboard/kibana.json +++ b/src/plugins/dashboard/kibana.json @@ -23,6 +23,7 @@ "requiredBundles": [ "home", "kibanaReact", - "kibanaUtils" + "kibanaUtils", + "presentationUtil" ] } diff --git a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx index a82aa78b815eca..ef0cd376df98b0 100644 --- a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx @@ -11,6 +11,12 @@ import angular from 'angular'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import UseUnmount from 'react-use/lib/useUnmount'; +import { + AddFromLibraryButton, + PrimaryActionButton, + QuickButtonGroup, + SolutionToolbar, +} from '../../../../presentation_util/public'; import { useKibana } from '../../services/kibana_react'; import { IndexPattern, SavedQuery, TimefilterContract } from '../../services/data'; import { @@ -43,9 +49,9 @@ import { showCloneModal } from './show_clone_modal'; import { showOptionsPopover } from './show_options_popover'; import { TopNavIds } from './top_nav_ids'; import { ShowShareModal } from './show_share_modal'; -import { PanelToolbar } from './panel_toolbar'; import { confirmDiscardOrKeepUnsavedChanges } from '../listing/confirm_overlays'; import { OverlayRef } from '../../../../../core/public'; +import { DashboardConstants } from '../../dashboard_constants'; import { getNewDashboardTitle, unsavedChangesBadge } from '../../dashboard_strings'; import { DASHBOARD_PANELS_UNSAVED_ID } from '../lib/dashboard_panel_storage'; import { DashboardContainer } from '..'; @@ -103,6 +109,8 @@ export function DashboardTopNav({ const [state, setState] = useState({ chromeIsVisible: false }); const [isSaveInProgress, setIsSaveInProgress] = useState(false); + const stateTransferService = embeddable.getStateTransfer(); + useEffect(() => { const visibleSubscription = chrome.getIsVisible$().subscribe((chromeIsVisible) => { setState((s) => ({ ...s, chromeIsVisible })); @@ -147,12 +155,26 @@ export function DashboardTopNav({ const createNew = useCallback(async () => { const type = 'visualization'; const factory = embeddable.getEmbeddableFactory(type); + if (!factory) { throw new EmbeddableFactoryNotFoundError(type); } + await factory.create({} as EmbeddableInput, dashboardContainer); }, [dashboardContainer, embeddable]); + const createNewVisType = useCallback( + (newVisType: string) => async () => { + stateTransferService.navigateToEditor('visualize', { + path: `#/create?type=${encodeURIComponent(newVisType)}`, + state: { + originatingApp: DashboardConstants.DASHBOARDS_ID, + }, + }); + }, + [stateTransferService] + ); + const clearAddPanel = useCallback(() => { if (state.addPanelOverlay) { state.addPanelOverlay.close(); @@ -540,11 +562,51 @@ export function DashboardTopNav({ }; const { TopNavMenu } = navigation.ui; + + const quickButtons = [ + { + iconType: 'visText', + createType: i18n.translate('dashboard.solutionToolbar.markdownQuickButtonLabel', { + defaultMessage: 'Markdown', + }), + onClick: createNewVisType('markdown'), + 'data-test-subj': 'dashboardMarkdownQuickButton', + }, + { + iconType: 'controlsHorizontal', + createType: i18n.translate('dashboard.solutionToolbar.inputControlsQuickButtonLabel', { + defaultMessage: 'Input control', + }), + onClick: createNewVisType('input_control_vis'), + 'data-test-subj': 'dashboardInputControlsQuickButton', + }, + ]; + return ( <> {viewMode !== ViewMode.VIEW ? ( - + + {{ + primaryActionButton: ( + + ), + quickButtonGroup: , + addFromLibraryButton: ( + + ), + }} + ) : null} ); diff --git a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/__snapshots__/panel_toolbar.stories.storyshot b/src/plugins/dashboard/public/application/top_nav/panel_toolbar/__snapshots__/panel_toolbar.stories.storyshot deleted file mode 100644 index afbbecb3935e0e..00000000000000 --- a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/__snapshots__/panel_toolbar.stories.storyshot +++ /dev/null @@ -1,71 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Storyshots components/PanelToolbar default 1`] = ` -

-
- -
-
- -
-
-`; diff --git a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.tsx b/src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.tsx deleted file mode 100644 index 0449fae80186d0..00000000000000 --- a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.tsx +++ /dev/null @@ -1,51 +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 - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import './panel_toolbar.scss'; -import React, { FC } from 'react'; -import { i18n } from '@kbn/i18n'; -import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; - -interface Props { - /** The click handler for the Add Panel button for creating new panels */ - onAddPanelClick: () => void; - /** The click handler for the Library button for adding existing visualizations/embeddables */ - onLibraryClick: () => void; -} - -export const PanelToolbar: FC = ({ onAddPanelClick, onLibraryClick }) => ( - - - - {i18n.translate('dashboard.panelToolbar.addPanelButtonLabel', { - defaultMessage: 'Create panel', - })} - - - - - {i18n.translate('dashboard.panelToolbar.libraryButtonLabel', { - defaultMessage: 'Add from library', - })} - - - -); diff --git a/src/plugins/dashboard/tsconfig.json b/src/plugins/dashboard/tsconfig.json index dd99119cfb4570..12820fc08310d6 100644 --- a/src/plugins/dashboard/tsconfig.json +++ b/src/plugins/dashboard/tsconfig.json @@ -32,5 +32,8 @@ { "path": "../saved_objects/tsconfig.json" }, { "path": "../ui_actions/tsconfig.json" }, { "path": "../spaces_oss/tsconfig.json" }, + { "path": "../charts/tsconfig.json" }, + { "path": "../discover/tsconfig.json" }, + { "path": "../visualizations/tsconfig.json" }, ] } diff --git a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/index.ts b/src/plugins/presentation_util/public/components/solution_toolbar/index.ts similarity index 81% rename from src/plugins/dashboard/public/application/top_nav/panel_toolbar/index.ts rename to src/plugins/presentation_util/public/components/solution_toolbar/index.ts index fd0ce66beb97c7..332d60787b8cbb 100644 --- a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/index.ts +++ b/src/plugins/presentation_util/public/components/solution_toolbar/index.ts @@ -6,4 +6,5 @@ * Side Public License, v 1. */ -export { PanelToolbar } from './panel_toolbar'; +export { SolutionToolbar } from './solution_toolbar'; +export * from './items'; diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/add_from_library.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/add_from_library.tsx new file mode 100644 index 00000000000000..0550de1d069fa0 --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/add_from_library.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { ComponentStrings } from '../../../i18n/components'; +import { SolutionToolbarButton, Props as SolutionToolbarButtonProps } from './button'; + +const { SolutionToolbar: strings } = ComponentStrings; + +export type Props = Omit; + +export const AddFromLibraryButton = ({ onClick, ...rest }: Props) => ( + +); diff --git a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.scss b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss similarity index 73% rename from src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.scss rename to src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss index 9ad6a5257df3ea..79c3d4cca7ace1 100644 --- a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.scss +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss @@ -1,9 +1,5 @@ -.panelToolbar { - padding: 0 $euiSizeS $euiSizeS; - flex-grow: 0; -} -.panelToolbarButton { +.solutionToolbarButton { line-height: $euiButtonHeight; // Keeps alignment of text and chart icon background-color: $euiColorEmptyShade; diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/button.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.tsx new file mode 100644 index 00000000000000..5de8e24ef5f0de --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiButton } from '@elastic/eui'; +import { EuiButtonPropsForButton } from '@elastic/eui/src/components/button/button'; + +import './button.scss'; + +export interface Props extends Pick { + label: string; + primary?: boolean; +} + +export const SolutionToolbarButton = ({ label, primary, ...rest }: Props) => ( + + {label} + +); diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/index.ts b/src/plugins/presentation_util/public/components/solution_toolbar/items/index.ts new file mode 100644 index 00000000000000..654831e86d3f68 --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { SolutionToolbarButton } from './button'; +export { SolutionToolbarPopover } from './popover'; +export { AddFromLibraryButton } from './add_from_library'; +export { QuickButtonProps, QuickButtonGroup } from './quick_group'; +export { PrimaryActionButton } from './primary_button'; +export { PrimaryActionPopover } from './primary_popover'; diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx new file mode 100644 index 00000000000000..fbb34e165190d5 --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState } from 'react'; +import { EuiPopover } from '@elastic/eui'; +import { Props as EuiPopoverProps } from '@elastic/eui/src/components/popover/popover'; + +import { SolutionToolbarButton, Props as ButtonProps } from './button'; + +type AllowedButtonProps = Omit; +type AllowedPopoverProps = Omit< + EuiPopoverProps, + 'button' | 'isOpen' | 'closePopover' | 'anchorPosition' +>; + +export type Props = AllowedButtonProps & AllowedPopoverProps; + +export const SolutionToolbarPopover = ({ label, iconType, primary, ...popover }: Props) => { + const [isOpen, setIsOpen] = useState(false); + + const onButtonClick = () => setIsOpen((status) => !status); + const closePopover = () => setIsOpen(false); + + const button = ( + + ); + + return ( + + ); +}; diff --git a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.stories.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.tsx similarity index 53% rename from src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.stories.tsx rename to src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.tsx index 5525b1cd069ad4..e2ef75e45a4049 100644 --- a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.stories.tsx +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.tsx @@ -6,14 +6,10 @@ * Side Public License, v 1. */ -import { storiesOf } from '@storybook/react'; -import { action } from '@storybook/addon-actions'; import React from 'react'; -import { PanelToolbar } from './panel_toolbar'; -storiesOf('components/PanelToolbar', module).add('default', () => ( - -)); +import { SolutionToolbarButton, Props as SolutionToolbarButtonProps } from './button'; + +export const PrimaryActionButton = (props: Omit) => ( + +); diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_popover.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_popover.tsx new file mode 100644 index 00000000000000..164d4c9b4a1a62 --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_popover.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; + +import { SolutionToolbarPopover, Props as SolutionToolbarPopoverProps } from './popover'; + +export type Props = Omit; + +export const PrimaryActionPopover = (props: Omit) => ( + +); diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss new file mode 100644 index 00000000000000..639ff5bf2a117a --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss @@ -0,0 +1,5 @@ +.quickButtonGroup { + .quickButtonGroup__button { + background-color: $euiColorEmptyShade; + } +} diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx new file mode 100644 index 00000000000000..58f8bd803b636a --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiButtonGroup, htmlIdGenerator, EuiButtonGroupOptionProps } from '@elastic/eui'; +import { ComponentStrings } from '../../../i18n/components'; + +const { QuickButtonGroup: strings } = ComponentStrings; + +import './quick_group.scss'; + +export interface QuickButtonProps extends Pick { + createType: string; + onClick: () => void; +} + +export interface Props { + buttons: QuickButtonProps[]; +} + +type Option = EuiButtonGroupOptionProps & Omit; + +export const QuickButtonGroup = ({ buttons }: Props) => { + const buttonGroupOptions: Option[] = buttons.map((button: QuickButtonProps, index) => { + const { createType: label, ...rest } = button; + const title = strings.getAriaButtonLabel(label); + + return { + ...rest, + 'aria-label': title, + className: 'quickButtonGroup__button', + id: `${htmlIdGenerator()()}${index}`, + label, + title, + }; + }); + + const onChangeIconsMulti = (optionId: string) => { + buttonGroupOptions.find((x) => x.id === optionId)?.onClick(); + }; + + return ( + + ); +}; diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.scss b/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.scss new file mode 100644 index 00000000000000..18160acf191e44 --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.scss @@ -0,0 +1,4 @@ +.solutionToolbar { + padding: 0 $euiSizeS $euiSizeS; + flex-grow: 0; +} diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.stories.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.stories.tsx new file mode 100644 index 00000000000000..fa33f53f9ae4f8 --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.stories.tsx @@ -0,0 +1,205 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { Story } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { EuiContextMenu } from '@elastic/eui'; + +import { SolutionToolbar } from './solution_toolbar'; +import { SolutionToolbarPopover } from './items'; +import { AddFromLibraryButton, PrimaryActionButton, QuickButtonGroup } from './items'; + +const quickButtons = [ + { + createType: 'Text', + onClick: action('onTextClick'), + iconType: 'visText', + }, + { + createType: 'Control', + onClick: action('onControlClick'), + iconType: 'controlsHorizontal', + }, + { + createType: 'Link', + onClick: action('onLinkClick'), + iconType: 'link', + }, + { + createType: 'Image', + onClick: action('onImageClick'), + iconType: 'image', + }, + { + createType: 'Markup', + onClick: action('onMarkupClick'), + iconType: 'visVega', + }, +]; + +const primaryButtonConfigs = { + Generic: ( + + ), + Canvas: ( + + + + ), + Dashboard: ( + + ), +}; + +const extraButtonConfigs = { + Generic: undefined, + Canvas: undefined, + Dashboard: [ + + + , + ], +}; + +export default { + title: 'Solution Toolbar', + description: 'A universal toolbar for solutions maintained by the Presentation Team.', + component: SolutionToolbar, + argTypes: { + quickButtonCount: { + defaultValue: 2, + control: { + type: 'number', + min: 0, + max: 5, + step: 1, + }, + }, + showAddFromLibraryButton: { + defaultValue: true, + control: { + type: 'boolean', + }, + }, + solution: { + table: { + disable: true, + }, + }, + }, + // https://github.com/storybookjs/storybook/issues/11543#issuecomment-684130442 + parameters: { + docs: { + source: { + type: 'code', + }, + }, + }, +}; + +const Template: Story<{ + solution: 'Generic' | 'Canvas' | 'Dashboard'; + quickButtonCount: number; + showAddFromLibraryButton: boolean; +}> = ({ quickButtonCount, solution, showAddFromLibraryButton }) => { + const primaryActionButton = primaryButtonConfigs[solution]; + const extraButtons = extraButtonConfigs[solution]; + let quickButtonGroup; + let addFromLibraryButton; + + if (quickButtonCount > 0) { + quickButtonGroup = ; + } + + if (showAddFromLibraryButton) { + addFromLibraryButton = ; + } + + return ( + + {{ + primaryActionButton, + quickButtonGroup, + extraButtons, + addFromLibraryButton, + }} + + ); +}; + +export const Generic = Template.bind({}); +Generic.args = { + ...Template.args, + solution: 'Generic', +}; + +export const Canvas = Template.bind({}); +Canvas.args = { + ...Template.args, + solution: 'Canvas', +}; + +export const Dashboard = Template.bind({}); +Dashboard.args = { + ...Template.args, + solution: 'Dashboard', +}; diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.tsx new file mode 100644 index 00000000000000..bb8b04e8b8f09c --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { ReactElement } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { + AddFromLibraryButton, + QuickButtonGroup, + PrimaryActionButton, + SolutionToolbarButton, + PrimaryActionPopover, + SolutionToolbarPopover, +} from './items'; + +import './solution_toolbar.scss'; + +interface NamedSlots { + primaryActionButton: ReactElement; + quickButtonGroup?: ReactElement; + addFromLibraryButton?: ReactElement; + extraButtons?: Array>; +} + +export interface Props { + children: NamedSlots; +} + +export const SolutionToolbar = ({ children }: Props) => { + const { + primaryActionButton, + quickButtonGroup, + addFromLibraryButton, + extraButtons = [], + } = children; + + const extra = extraButtons.map((button, index) => + button ? ( + + {button} + + ) : null + ); + + return ( + + {primaryActionButton} + {quickButtonGroup ? {quickButtonGroup} : null} + {extra} + {addFromLibraryButton ? {addFromLibraryButton} : null} + + ); +}; diff --git a/src/plugins/presentation_util/public/i18n/components.ts b/src/plugins/presentation_util/public/i18n/components.ts new file mode 100644 index 00000000000000..ab0e6d1bdbda0c --- /dev/null +++ b/src/plugins/presentation_util/public/i18n/components.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +export const ComponentStrings = { + SolutionToolbar: { + getEditorMenuButtonLabel: () => + i18n.translate('presentationUtil.solutionToolbar.editorMenuButtonLabel', { + defaultMessage: 'All editors', + }), + getLibraryButtonLabel: () => + i18n.translate('presentationUtil.solutionToolbar.libraryButtonLabel', { + defaultMessage: 'Add from library', + }), + }, + QuickButtonGroup: { + getAriaButtonLabel: (createType: string) => + i18n.translate('presentationUtil.solutionToolbar.quickButton.ariaButtonLabel', { + defaultMessage: `Create new {createType}`, + values: { + createType, + }, + }), + getLegend: () => + i18n.translate('presentationUtil.solutionToolbar.quickButton.legendLabel', { + defaultMessage: 'Quick create', + }), + }, +}; diff --git a/src/plugins/presentation_util/public/index.ts b/src/plugins/presentation_util/public/index.ts index 1cbf4b5a4f3347..9c5f65de409555 100644 --- a/src/plugins/presentation_util/public/index.ts +++ b/src/plugins/presentation_util/public/index.ts @@ -19,6 +19,16 @@ export { LazySavedObjectSaveModalDashboard, withSuspense, } from './components'; +export { + AddFromLibraryButton, + PrimaryActionButton, + PrimaryActionPopover, + QuickButtonGroup, + QuickButtonProps, + SolutionToolbar, + SolutionToolbarButton, + SolutionToolbarPopover, +} from './components/solution_toolbar'; export function plugin() { return new PresentationUtilPlugin(); diff --git a/src/plugins/presentation_util/tsconfig.json b/src/plugins/presentation_util/tsconfig.json index 63d136cf9445a6..c0fafe8c3aabad 100644 --- a/src/plugins/presentation_util/tsconfig.json +++ b/src/plugins/presentation_util/tsconfig.json @@ -15,11 +15,8 @@ "../../../typings/**/*" ], "references": [ - { - "path": "../../core/tsconfig.json" - }, - { - "path": "../saved_objects/tsconfig.json" - }, + { "path": "../../core/tsconfig.json" }, + { "path": "../saved_objects/tsconfig.json" }, + { "path": "../kibana_react/tsconfig.json" }, ] } diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx index c2b9fcd77757a4..2b5a611cd946e8 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx @@ -58,7 +58,7 @@ interface VisualizationAttributes extends SavedObjectAttributes { export interface VisualizeEmbeddableFactoryDeps { start: StartServicesGetter< - Pick + Pick >; } diff --git a/src/plugins/visualizations/public/mocks.ts b/src/plugins/visualizations/public/mocks.ts index 8f1ebe25b50599..901593626a9451 100644 --- a/src/plugins/visualizations/public/mocks.ts +++ b/src/plugins/visualizations/public/mocks.ts @@ -17,7 +17,6 @@ import { dataPluginMock } from '../../../plugins/data/public/mocks'; import { usageCollectionPluginMock } from '../../../plugins/usage_collection/public/mocks'; import { uiActionsPluginMock } from '../../../plugins/ui_actions/public/mocks'; import { inspectorPluginMock } from '../../../plugins/inspector/public/mocks'; -import { dashboardPluginMock } from '../../../plugins/dashboard/public/mocks'; import { savedObjectsPluginMock } from '../../../plugins/saved_objects/public/mocks'; const createSetupContract = (): VisualizationsSetup => ({ @@ -62,7 +61,6 @@ const createInstance = async () => { uiActions: uiActionsPluginMock.createStartContract(), application: applicationServiceMock.createStartContract(), embeddable: embeddablePluginMock.createStartContract(), - dashboard: dashboardPluginMock.createStartContract(), getAttributeService: jest.fn(), savedObjectsClient: coreMock.createStart().savedObjects.client, savedObjects: savedObjectsPluginMock.createStartContract(), diff --git a/src/plugins/visualizations/public/plugin.ts b/src/plugins/visualizations/public/plugin.ts index d4e7132a1a21ea..081f5d65103c20 100644 --- a/src/plugins/visualizations/public/plugin.ts +++ b/src/plugins/visualizations/public/plugin.ts @@ -62,7 +62,6 @@ import { convertToSerializedVis, } from './saved_visualizations/_saved_vis'; import { createSavedSearchesLoader } from '../../discover/public'; -import { DashboardStart } from '../../dashboard/public'; import { SavedObjectsStart } from '../../saved_objects/public'; /** @@ -97,7 +96,6 @@ export interface VisualizationsStartDeps { inspector: InspectorStart; uiActions: UiActionsStart; application: ApplicationStart; - dashboard: DashboardStart; getAttributeService: EmbeddableStart['getAttributeService']; savedObjects: SavedObjectsStart; savedObjectsClient: SavedObjectsClientContract; @@ -145,7 +143,7 @@ export class VisualizationsPlugin public start( core: CoreStart, - { data, expressions, uiActions, embeddable, dashboard, savedObjects }: VisualizationsStartDeps + { data, expressions, uiActions, embeddable, savedObjects }: VisualizationsStartDeps ): VisualizationsStart { const types = this.types.start(); setTypes(types); diff --git a/src/plugins/visualizations/tsconfig.json b/src/plugins/visualizations/tsconfig.json index d7c5e6a4b43663..356448aa59771e 100644 --- a/src/plugins/visualizations/tsconfig.json +++ b/src/plugins/visualizations/tsconfig.json @@ -15,7 +15,6 @@ "references": [ { "path": "../../core/tsconfig.json" }, { "path": "../data/tsconfig.json" }, - { "path": "../dashboard/tsconfig.json" }, { "path": "../expressions/tsconfig.json" }, { "path": "../ui_actions/tsconfig.json" }, { "path": "../embeddable/tsconfig.json" }, diff --git a/test/functional/apps/dashboard/create_and_add_embeddables.ts b/test/functional/apps/dashboard/create_and_add_embeddables.ts index f4ee4e99047686..9b8fc4785a6718 100644 --- a/test/functional/apps/dashboard/create_and_add_embeddables.ts +++ b/test/functional/apps/dashboard/create_and_add_embeddables.ts @@ -69,6 +69,36 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.waitForRenderComplete(); }); + it('adds a markdown visualization via the quick button', async () => { + const originalPanelCount = await PageObjects.dashboard.getPanelCount(); + await PageObjects.dashboard.clickMarkdownQuickButton(); + await PageObjects.visualize.saveVisualizationExpectSuccess( + 'visualization from markdown quick button', + { redirectToOrigin: true } + ); + + await retry.try(async () => { + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(originalPanelCount + 1); + }); + await PageObjects.dashboard.waitForRenderComplete(); + }); + + it('adds an input control visualization via the quick button', async () => { + const originalPanelCount = await PageObjects.dashboard.getPanelCount(); + await PageObjects.dashboard.clickInputControlsQuickButton(); + await PageObjects.visualize.saveVisualizationExpectSuccess( + 'visualization from input control quick button', + { redirectToOrigin: true } + ); + + await retry.try(async () => { + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(originalPanelCount + 1); + }); + await PageObjects.dashboard.waitForRenderComplete(); + }); + it('saves the listing page instead of the visualization to the app link', async () => { await PageObjects.header.clickVisualize(true); const currentUrl = await browser.getCurrentUrl(); diff --git a/test/functional/apps/dashboard/edit_visualizations.js b/test/functional/apps/dashboard/edit_visualizations.js index d5df97881a1d3b..ce32f53587e747 100644 --- a/test/functional/apps/dashboard/edit_visualizations.js +++ b/test/functional/apps/dashboard/edit_visualizations.js @@ -15,15 +15,12 @@ export default function ({ getService, getPageObjects }) { const appsMenu = getService('appsMenu'); const kibanaServer = getService('kibanaServer'); const dashboardPanelActions = getService('dashboardPanelActions'); - const dashboardVisualizations = getService('dashboardVisualizations'); const originalMarkdownText = 'Original markdown text'; const modifiedMarkdownText = 'Modified markdown text'; const createMarkdownVis = async (title) => { - await testSubjects.click('dashboardAddNewPanelButton'); - await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); - await PageObjects.visualize.clickMarkdownWidget(); + await PageObjects.dashboard.clickMarkdownQuickButton(); await PageObjects.visEditor.setMarkdownTxt(originalMarkdownText); await PageObjects.visEditor.clickGo(); if (title) { diff --git a/test/functional/page_objects/dashboard_page.ts b/test/functional/page_objects/dashboard_page.ts index 9c12296db138c6..34559afdf6ae1a 100644 --- a/test/functional/page_objects/dashboard_page.ts +++ b/test/functional/page_objects/dashboard_page.ts @@ -413,6 +413,16 @@ export function DashboardPageProvider({ getService, getPageObjects }: FtrProvide await testSubjects.click('confirmSaveSavedObjectButton'); } + public async clickMarkdownQuickButton() { + log.debug('Click markdown quick button'); + await testSubjects.click('dashboardMarkdownQuickButton'); + } + + public async clickInputControlsQuickButton() { + log.debug('Click input controls quick button'); + await testSubjects.click('dashboardInputControlsQuickButton'); + } + /** * * @param dashboardTitle {String} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 7eb1fb458351a3..63580981cb3203 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -669,8 +669,7 @@ "dashboard.panelStorageError.clearError": "保存されていない変更の消去中にエラーが発生しました。{message}", "dashboard.panelStorageError.getError": "保存されていない変更の取得中にエラーが発生しました。{message}", "dashboard.panelStorageError.setError": "保存されていない変更の設定中にエラーが発生しました。{message}", - "dashboard.panelToolbar.addPanelButtonLabel": "パネルの作成", - "dashboard.panelToolbar.libraryButtonLabel": "ライブラリから追加", + "dashboard.solutionToolbar.addPanelButtonLabel": "パネルの作成", "dashboard.placeholder.factory.displayName": "プレースホルダー", "dashboard.savedDashboard.newDashboardTitle": "新規ダッシュボード", "dashboard.stateManager.timeNotSavedWithDashboardErrorMessage": "このダッシュボードに時刻が保存されていないため、同期できません。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 7e80a52d229c46..77ef19e61030aa 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -672,8 +672,7 @@ "dashboard.panelStorageError.clearError": "清除未保存更改时遇到错误:{message}", "dashboard.panelStorageError.getError": "获取未保存更改时遇到错误:{message}", "dashboard.panelStorageError.setError": "设置未保存更改时遇到错误:{message}", - "dashboard.panelToolbar.addPanelButtonLabel": "创建面板", - "dashboard.panelToolbar.libraryButtonLabel": "从库中添加", + "dashboard.solutionToolbar.addPanelButtonLabel": "创建面板", "dashboard.placeholder.factory.displayName": "占位符", "dashboard.savedDashboard.newDashboardTitle": "新建仪表板", "dashboard.stateManager.timeNotSavedWithDashboardErrorMessage": "时间未随此仪表板保存,因此无法同步。", From 27cd514cab01e91571c1680d0971994471e25ddc Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Tue, 13 Apr 2021 10:21:06 -0700 Subject: [PATCH 41/90] Use doc link services in index management (#89957) Co-authored-by: Alison Goryachev --- ...-plugin-core-public.doclinksstart.links.md | 6 +- ...kibana-plugin-core-public.doclinksstart.md | 2 +- .../public/doc_links/doc_links_service.ts | 56 +++++- src/core/public/public.api.md | 6 +- .../sessions_mgmt/components/main.test.tsx | 12 +- .../search/sessions_mgmt/lib/documentation.ts | 9 +- .../component_templates/lib/documentation.ts | 11 +- .../application/services/documentation.ts | 190 ++++++++++++------ 8 files changed, 212 insertions(+), 80 deletions(-) diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index 860f7c3c748924..01079bdf03d0cd 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -88,6 +88,7 @@ readonly links: { readonly top_hits: string; }; readonly runtimeFields: { + readonly overview: string; readonly mapping: string; }; readonly scriptedFields: { @@ -114,9 +115,10 @@ readonly links: { }; readonly query: { readonly eql: string; + readonly kueryQuerySyntax: string; readonly luceneQuerySyntax: string; + readonly percolate: string; readonly queryDsl: string; - readonly kueryQuerySyntax: string; }; readonly date: { readonly dateMath: string; @@ -127,6 +129,7 @@ readonly links: { readonly transforms: Record; readonly visualize: Record; readonly apis: Readonly<{ + bulkIndexAlias: string; createIndex: string; createSnapshotLifecyclePolicy: string; createRoleMapping: string; @@ -143,6 +146,7 @@ readonly links: { painlessExecuteAPIContexts: string; putComponentTemplateMetadata: string; putSnapshotLifecyclePolicy: string; + putIndexTemplateV1: string; putWatch: string; simulatePipeline: string; updateTransform: string; diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md index a9cb6729b214e6..11814e7ca8b771 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -17,5 +17,5 @@ export interface DocLinksStart | --- | --- | --- | | [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | | [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | -| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
};
readonly addData: string;
readonly kibana: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putWatch: string;
simulatePipeline: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
} | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly overview: string;
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
};
readonly addData: string;
readonly kibana: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putIndexTemplateV1: string;
putWatch: string;
simulatePipeline: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
} | | diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index baf8ed2a61645b..1bff91f15a150e 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -109,6 +109,7 @@ export class DocLinksService { top_hits: `${ELASTICSEARCH_DOCS}search-aggregations-metrics-top-hits-aggregation.html`, }, runtimeFields: { + overview: `${ELASTICSEARCH_DOCS}runtime.html`, mapping: `${ELASTICSEARCH_DOCS}runtime-mapping-fields.html`, }, scriptedFields: { @@ -130,8 +131,49 @@ export class DocLinksService { addData: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/connect-to-elasticsearch.html`, kibana: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/index.html`, elasticsearch: { + docsBase: `${ELASTICSEARCH_DOCS}`, + asyncSearch: `${ELASTICSEARCH_DOCS}async-search-intro.html`, + dataStreams: `${ELASTICSEARCH_DOCS}data-streams.html`, indexModules: `${ELASTICSEARCH_DOCS}index-modules.html`, + indexSettings: `${ELASTICSEARCH_DOCS}index-modules.html#index-modules-settings`, + indexTemplates: `${ELASTICSEARCH_DOCS}indices-templates.html`, mapping: `${ELASTICSEARCH_DOCS}mapping.html`, + mappingAnalyzer: `${ELASTICSEARCH_DOCS}analyzer.html`, + mappingCoerce: `${ELASTICSEARCH_DOCS}coerce.html`, + mappingCopyTo: `${ELASTICSEARCH_DOCS}copy-to.html`, + mappingDocValues: `${ELASTICSEARCH_DOCS}doc-values.html`, + mappingDynamic: `${ELASTICSEARCH_DOCS}dynamic.html`, + mappingDynamicFields: `${ELASTICSEARCH_DOCS}dynamic-field-mapping.html`, + mappingDynamicTemplates: `${ELASTICSEARCH_DOCS}dynamic-templates.html`, + mappingEagerGlobalOrdinals: `${ELASTICSEARCH_DOCS}eager-global-ordinals.html`, + mappingEnabled: `${ELASTICSEARCH_DOCS}enabled.html`, + mappingFieldData: `${ELASTICSEARCH_DOCS}text.html#fielddata-mapping-param`, + mappingFieldDataEnable: `${ELASTICSEARCH_DOCS}text.html#before-enabling-fielddata`, + mappingFieldDataFilter: `${ELASTICSEARCH_DOCS}text.html#field-data-filtering`, + mappingFieldDataTypes: `${ELASTICSEARCH_DOCS}mapping-types.html`, + mappingFormat: `${ELASTICSEARCH_DOCS}mapping-date-format.html`, + mappingIgnoreAbove: `${ELASTICSEARCH_DOCS}ignore-above.html`, + mappingIgnoreMalformed: `${ELASTICSEARCH_DOCS}ignore-malformed.html`, + mappingIndex: `${ELASTICSEARCH_DOCS}mapping-index.html`, + mappingIndexOptions: `${ELASTICSEARCH_DOCS}index-options.html`, + mappingIndexPhrases: `${ELASTICSEARCH_DOCS}index-phrases.html`, + mappingIndexPrefixes: `${ELASTICSEARCH_DOCS}index-prefixes.html`, + mappingJoinFieldsPerformance: `${ELASTICSEARCH_DOCS}parent-join.html#_parent_join_and_performance`, + mappingMeta: `${ELASTICSEARCH_DOCS}mapping-field-meta.html`, + mappingMetaFields: `${ELASTICSEARCH_DOCS}mapping-meta-field.html`, + mappingNormalizer: `${ELASTICSEARCH_DOCS}normalizer.html`, + mappingNorms: `${ELASTICSEARCH_DOCS}norms.html`, + mappingNullValue: `${ELASTICSEARCH_DOCS}null-value.html`, + mappingParameters: `${ELASTICSEARCH_DOCS}mapping-params.html`, + mappingPositionIncrementGap: `${ELASTICSEARCH_DOCS}position-increment-gap.html`, + mappingRankFeatureFields: `${ELASTICSEARCH_DOCS}rank-feature.html`, + mappingRouting: `${ELASTICSEARCH_DOCS}mapping-routing-field.html`, + mappingSimilarity: `${ELASTICSEARCH_DOCS}similarity.html`, + mappingSourceFields: `${ELASTICSEARCH_DOCS}mapping-source-field.html`, + mappingSourceFieldsDisable: `${ELASTICSEARCH_DOCS}mapping-source-field.html#disable-source-field`, + mappingStore: `${ELASTICSEARCH_DOCS}mapping-store.html`, + mappingTermVector: `${ELASTICSEARCH_DOCS}term-vector.html`, + mappingTypesRemoval: `${ELASTICSEARCH_DOCS}removal-of-types.html`, nodeRoles: `${ELASTICSEARCH_DOCS}modules-node.html#node-roles`, remoteClusters: `${ELASTICSEARCH_DOCS}modules-remote-clusters.html`, remoteClustersProxy: `${ELASTICSEARCH_DOCS}modules-remote-clusters.html#proxy-mode`, @@ -146,17 +188,19 @@ export class DocLinksService { }, query: { eql: `${ELASTICSEARCH_DOCS}eql.html`, + kueryQuerySyntax: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kuery-query.html`, luceneQuerySyntax: `${ELASTICSEARCH_DOCS}query-dsl-query-string-query.html#query-string-syntax`, + percolate: `${ELASTICSEARCH_DOCS}query-dsl-percolate-query.html`, queryDsl: `${ELASTICSEARCH_DOCS}query-dsl.html`, - kueryQuerySyntax: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kuery-query.html`, }, date: { dateMath: `${ELASTICSEARCH_DOCS}common-options.html#date-math`, dateMathIndexNames: `${ELASTICSEARCH_DOCS}date-math-index-names.html`, }, management: { - kibanaSearchSettings: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/advanced-options.html#kibana-search-settings`, dashboardSettings: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/advanced-options.html#kibana-dashboard-settings`, + indexManagement: `${ELASTICSEARCH_DOCS}index-mgmt.html`, + kibanaSearchSettings: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/advanced-options.html#kibana-search-settings`, visualizationSettings: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/advanced-options.html#kibana-visualization-settings`, }, ml: { @@ -258,6 +302,7 @@ export class DocLinksService { skippingDisconnectedClusters: `${ELASTICSEARCH_DOCS}modules-cross-cluster-search.html#skip-unavailable-clusters`, }, apis: { + bulkIndexAlias: `${ELASTICSEARCH_DOCS}indices-aliases.html`, createIndex: `${ELASTICSEARCH_DOCS}indices-create-index.html`, createSnapshotLifecyclePolicy: `${ELASTICSEARCH_DOCS}slm-api-put-policy.html`, createRoleMapping: `${ELASTICSEARCH_DOCS}security-api-put-role-mapping.html`, @@ -274,6 +319,7 @@ export class DocLinksService { painlessExecuteAPIContexts: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-execute-api.html#_contexts`, putComponentTemplateMetadata: `${ELASTICSEARCH_DOCS}indices-component-template.html#component-templates-metadata`, putEnrichPolicy: `${ELASTICSEARCH_DOCS}put-enrich-policy-api.html`, + putIndexTemplateV1: `${ELASTICSEARCH_DOCS}indices-templates-v1.html`, putSnapshotLifecyclePolicy: `${ELASTICSEARCH_DOCS}slm-api-put-policy.html`, putWatch: `${ELASTICSEARCH_DOCS}watcher-api-put-watch.html`, simulatePipeline: `${ELASTICSEARCH_DOCS}simulate-pipeline-api.html`, @@ -429,6 +475,7 @@ export interface DocLinksStart { readonly top_hits: string; }; readonly runtimeFields: { + readonly overview: string; readonly mapping: string; }; readonly scriptedFields: { @@ -455,9 +502,10 @@ export interface DocLinksStart { }; readonly query: { readonly eql: string; + readonly kueryQuerySyntax: string; readonly luceneQuerySyntax: string; + readonly percolate: string; readonly queryDsl: string; - readonly kueryQuerySyntax: string; }; readonly date: { readonly dateMath: string; @@ -468,6 +516,7 @@ export interface DocLinksStart { readonly transforms: Record; readonly visualize: Record; readonly apis: Readonly<{ + bulkIndexAlias: string; createIndex: string; createSnapshotLifecyclePolicy: string; createRoleMapping: string; @@ -484,6 +533,7 @@ export interface DocLinksStart { painlessExecuteAPIContexts: string; putComponentTemplateMetadata: string; putSnapshotLifecyclePolicy: string; + putIndexTemplateV1: string; putWatch: string; simulatePipeline: string; updateTransform: string; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 8327428991e13b..3f4de7fccac72b 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -571,6 +571,7 @@ export interface DocLinksStart { readonly top_hits: string; }; readonly runtimeFields: { + readonly overview: string; readonly mapping: string; }; readonly scriptedFields: { @@ -597,9 +598,10 @@ export interface DocLinksStart { }; readonly query: { readonly eql: string; + readonly kueryQuerySyntax: string; readonly luceneQuerySyntax: string; + readonly percolate: string; readonly queryDsl: string; - readonly kueryQuerySyntax: string; }; readonly date: { readonly dateMath: string; @@ -610,6 +612,7 @@ export interface DocLinksStart { readonly transforms: Record; readonly visualize: Record; readonly apis: Readonly<{ + bulkIndexAlias: string; createIndex: string; createSnapshotLifecyclePolicy: string; createRoleMapping: string; @@ -626,6 +629,7 @@ export interface DocLinksStart { painlessExecuteAPIContexts: string; putComponentTemplateMetadata: string; putSnapshotLifecyclePolicy: string; + putIndexTemplateV1: string; putWatch: string; simulatePipeline: string; updateTransform: string; diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/main.test.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/main.test.tsx index 6b94eccc4e7076..dcc39e9fb385a2 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/main.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/main.test.tsx @@ -57,9 +57,11 @@ describe('Background Search Session Management Main', () => { describe('renders', () => { const docLinks: DocLinksStart = { - ELASTIC_WEBSITE_URL: 'boo/', - DOC_LINK_VERSION: '#foo', - links: {} as any, + ELASTIC_WEBSITE_URL: `boo/`, + DOC_LINK_VERSION: `#foo`, + links: { + elasticsearch: { asyncSearch: `mock-url` } as any, + } as any, }; let main: ReactWrapper; @@ -93,9 +95,7 @@ describe('Background Search Session Management Main', () => { test('documentation link', () => { const docLink = main.find('a[href]').first(); expect(docLink.text()).toBe('Documentation'); - expect(docLink.prop('href')).toBe( - 'boo/guide/en/elasticsearch/reference/#foo/async-search-intro.html' - ); + expect(docLink.prop('href')).toBe('mock-url'); }); test('table is present', () => { diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/documentation.ts b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/documentation.ts index 19d37891446cff..38db89e88a6e1f 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/documentation.ts +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/documentation.ts @@ -8,16 +8,15 @@ import { DocLinksStart } from 'kibana/public'; export class AsyncSearchIntroDocumentation { - private docsBasePath: string = ''; + private docUrl: string = ''; constructor(docs: DocLinksStart) { - const { DOC_LINK_VERSION, ELASTIC_WEBSITE_URL } = docs; - const docsBase = `${ELASTIC_WEBSITE_URL}guide/en`; + const { links } = docs; // TODO: There should be Kibana documentation link about Search Sessions in Kibana - this.docsBasePath = `${docsBase}/elasticsearch/reference/${DOC_LINK_VERSION}`; + this.docUrl = links.elasticsearch.asyncSearch; } public getElasticsearchDocLink() { - return `${this.docsBasePath}/async-search-intro.html`; + return `${this.docUrl}`; } } diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/lib/documentation.ts b/x-pack/plugins/index_management/public/application/components/component_templates/lib/documentation.ts index 5c839262b62ed2..185e521e4a5b84 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/lib/documentation.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/lib/documentation.ts @@ -7,14 +7,11 @@ import { DocLinksStart } from 'src/core/public'; -// eslint-disable-next-line @typescript-eslint/naming-convention -export const getDocumentation = ({ ELASTIC_WEBSITE_URL, DOC_LINK_VERSION }: DocLinksStart) => { - const docsBase = `${ELASTIC_WEBSITE_URL}guide/en`; - const esDocsBase = `${docsBase}/elasticsearch/reference/${DOC_LINK_VERSION}`; - +export const getDocumentation = ({ links }: DocLinksStart) => { + const esDocsBase = links.elasticsearch.docsBase; return { esDocsBase, - componentTemplates: `${esDocsBase}/indices-component-template.html`, - componentTemplatesMetadata: `${esDocsBase}/indices-component-template.html#component-templates-metadata`, + componentTemplates: links.apis.putComponentTemplate, + componentTemplatesMetadata: links.apis.putComponentTemplateMetadata, }; }; diff --git a/x-pack/plugins/index_management/public/application/services/documentation.ts b/x-pack/plugins/index_management/public/application/services/documentation.ts index c81c71a32e7e23..3d6c6edf986e8e 100644 --- a/x-pack/plugins/index_management/public/application/services/documentation.ts +++ b/x-pack/plugins/index_management/public/application/services/documentation.ts @@ -10,15 +10,98 @@ import { DataType } from '../components/mappings_editor/types'; import { TYPE_DEFINITION } from '../components/mappings_editor/constants'; class DocumentationService { + private dataStreams: string = ''; private esDocsBase: string = ''; - private kibanaDocsBase: string = ''; - + private indexManagement: string = ''; + private indexSettings: string = ''; + private indexTemplates: string = ''; + private indexV1: string = ''; + private mapping: string = ''; + private mappingAnalyzer: string = ''; + private mappingCoerce: string = ''; + private mappingCopyTo: string = ''; + private mappingDocValues: string = ''; + private mappingDynamic: string = ''; + private mappingDynamicFields: string = ''; + private mappingDynamicTemplates: string = ''; + private mappingEagerGlobalOrdinals: string = ''; + private mappingEnabled: string = ''; + private mappingFieldData: string = ''; + private mappingFieldDataFilter: string = ''; + private mappingFieldDataTypes: string = ''; + private mappingFieldDataEnable: string = ''; + private mappingFormat: string = ''; + private mappingIgnoreAbove: string = ''; + private mappingIgnoreMalformed: string = ''; + private mappingIndex: string = ''; + private mappingIndexOptions: string = ''; + private mappingIndexPhrases: string = ''; + private mappingIndexPrefixes: string = ''; + private mappingJoinFieldsPerformance: string = ''; + private mappingMeta: string = ''; + private mappingMetaFields: string = ''; + private mappingNormalizer: string = ''; + private mappingNorms: string = ''; + private mappingNullValue: string = ''; + private mappingParameters: string = ''; + private mappingPositionIncrementGap: string = ''; + private mappingRankFeatureFields: string = ''; + private mappingRouting: string = ''; + private mappingSimilarity: string = ''; + private mappingSourceFields: string = ''; + private mappingSourceFieldsDisable: string = ''; + private mappingStore: string = ''; + private mappingTermVector: string = ''; + private mappingTypesRemoval: string = ''; + private percolate: string = ''; + private runtimeFields: string = ''; public setup(docLinks: DocLinksStart): void { - const { DOC_LINK_VERSION, ELASTIC_WEBSITE_URL } = docLinks; - const docsBase = `${ELASTIC_WEBSITE_URL}guide/en`; - - this.esDocsBase = `${docsBase}/elasticsearch/reference/${DOC_LINK_VERSION}`; - this.kibanaDocsBase = `${docsBase}/kibana/${DOC_LINK_VERSION}`; + const { links } = docLinks; + this.dataStreams = links.elasticsearch.dataStreams; + this.esDocsBase = links.elasticsearch.docsBase; + this.indexManagement = links.management.indexManagement; + this.indexSettings = links.elasticsearch.indexSettings; + this.indexTemplates = links.elasticsearch.indexTemplates; + this.indexV1 = links.apis.putIndexTemplateV1; + this.mapping = links.elasticsearch.mapping; + this.mappingAnalyzer = links.elasticsearch.mappingAnalyzer; + this.mappingCoerce = links.elasticsearch.mappingCoerce; + this.mappingCopyTo = links.elasticsearch.mappingCopyTo; + this.mappingDocValues = links.elasticsearch.mappingDocValues; + this.mappingDynamic = links.elasticsearch.mappingDynamic; + this.mappingDynamicFields = links.elasticsearch.mappingDynamicFields; + this.mappingDynamicTemplates = links.elasticsearch.mappingDynamicTemplates; + this.mappingEagerGlobalOrdinals = links.elasticsearch.mappingEagerGlobalOrdinals; + this.mappingEnabled = links.elasticsearch.mappingEnabled; + this.mappingFieldData = links.elasticsearch.mappingFieldData; + this.mappingFieldDataTypes = links.elasticsearch.mappingFieldDataTypes; + this.mappingFieldDataEnable = links.elasticsearch.mappingFieldDataEnable; + this.mappingFieldDataFilter = links.elasticsearch.mappingFieldDataFilter; + this.mappingFormat = links.elasticsearch.mappingFormat; + this.mappingIgnoreAbove = links.elasticsearch.mappingIgnoreAbove; + this.mappingIgnoreMalformed = links.elasticsearch.mappingIgnoreMalformed; + this.mappingIndex = links.elasticsearch.mappingIndex; + this.mappingIndexOptions = links.elasticsearch.mappingIndexOptions; + this.mappingIndexPhrases = links.elasticsearch.mappingIndexPhrases; + this.mappingIndexPrefixes = links.elasticsearch.mappingIndexPrefixes; + this.mappingJoinFieldsPerformance = links.elasticsearch.mappingJoinFieldsPerformance; + this.mappingMeta = links.elasticsearch.mappingMeta; + this.mappingMetaFields = links.elasticsearch.mappingMetaFields; + this.mappingNormalizer = links.elasticsearch.mappingNormalizer; + this.mappingNorms = links.elasticsearch.mappingNorms; + this.mappingNullValue = links.elasticsearch.mappingNullValue; + this.mappingParameters = links.elasticsearch.mappingParameters; + this.mappingPositionIncrementGap = links.elasticsearch.mappingPositionIncrementGap; + this.mappingRankFeatureFields = links.elasticsearch.mappingRankFeatureFields; + this.mappingRouting = links.elasticsearch.mappingRouting; + this.mappingSimilarity = links.elasticsearch.mappingSimilarity; + this.mappingSourceFields = links.elasticsearch.mappingSourceFields; + this.mappingSourceFieldsDisable = links.elasticsearch.mappingSourceFieldsDisable; + this.mappingStore = links.elasticsearch.mappingStore; + this.mappingTermVector = links.elasticsearch.mappingTermVector; + this.mappingTypesRemoval = links.elasticsearch.mappingTypesRemoval; + this.percolate = links.query.percolate; + this.runtimeFields = links.runtimeFields.overview; } public getEsDocsBase() { @@ -26,29 +109,27 @@ class DocumentationService { } public getSettingsDocumentationLink() { - return `${this.esDocsBase}/index-modules.html#index-modules-settings`; + return this.indexSettings; } public getMappingDocumentationLink() { - return `${this.esDocsBase}/mapping.html`; + return this.mapping; } public getRoutingLink() { - return `${this.esDocsBase}/mapping-routing-field.html`; + return this.mappingRouting; } public getDataStreamsDocumentationLink() { - return `${this.esDocsBase}/data-streams.html`; + return this.dataStreams; } public getTemplatesDocumentationLink(isLegacy = false) { - return isLegacy - ? `${this.esDocsBase}/indices-templates-v1.html` - : `${this.esDocsBase}/indices-templates.html`; + return isLegacy ? this.indexV1 : this.indexTemplates; } public getIdxMgmtDocumentationLink() { - return `${this.kibanaDocsBase}/managing-indices.html`; + return this.indexManagement; } public getTypeDocLink = (type: DataType, docType = 'main'): string | undefined => { @@ -63,157 +144,154 @@ class DocumentationService { } return `${this.esDocsBase}${typeDefinition.documentation[docType]}`; }; - public getMappingTypesLink() { - return `${this.esDocsBase}/mapping-types.html`; + return this.mappingFieldDataTypes; } - public getDynamicMappingLink() { - return `${this.esDocsBase}/dynamic-field-mapping.html`; + return this.mappingDynamicFields; } - public getPercolatorQueryLink() { - return `${this.esDocsBase}/query-dsl-percolate-query.html`; + return this.percolate; } public getRankFeatureQueryLink() { - return `${this.esDocsBase}/rank-feature.html`; + return this.mappingRankFeatureFields; } public getMetaFieldLink() { - return `${this.esDocsBase}/mapping-meta-field.html`; + return this.mappingMetaFields; } public getDynamicTemplatesLink() { - return `${this.esDocsBase}/dynamic-templates.html`; + return this.mappingDynamicTemplates; } public getMappingSourceFieldLink() { - return `${this.esDocsBase}/mapping-source-field.html`; + return this.mappingSourceFields; } public getDisablingMappingSourceFieldLink() { - return `${this.esDocsBase}/mapping-source-field.html#disable-source-field`; + return this.mappingSourceFieldsDisable; } public getNullValueLink() { - return `${this.esDocsBase}/null-value.html`; + return this.mappingNullValue; } public getTermVectorLink() { - return `${this.esDocsBase}/term-vector.html`; + return this.mappingTermVector; } public getStoreLink() { - return `${this.esDocsBase}/mapping-store.html`; + return this.mappingStore; } public getSimilarityLink() { - return `${this.esDocsBase}/similarity.html`; + return this.mappingSimilarity; } public getNormsLink() { - return `${this.esDocsBase}/norms.html`; + return this.mappingNorms; } public getIndexLink() { - return `${this.esDocsBase}/mapping-index.html`; + return this.mappingIndex; } public getIgnoreMalformedLink() { - return `${this.esDocsBase}/ignore-malformed.html`; + return this.mappingIgnoreMalformed; } public getMetaLink() { - return `${this.esDocsBase}/mapping-field-meta.html`; + return this.mappingMeta; } public getFormatLink() { - return `${this.esDocsBase}/mapping-date-format.html`; + return this.mappingFormat; } public getEagerGlobalOrdinalsLink() { - return `${this.esDocsBase}/eager-global-ordinals.html`; + return this.mappingEagerGlobalOrdinals; } public getDocValuesLink() { - return `${this.esDocsBase}/doc-values.html`; + return this.mappingDocValues; } public getCopyToLink() { - return `${this.esDocsBase}/copy-to.html`; + return this.mappingCopyTo; } public getCoerceLink() { - return `${this.esDocsBase}/coerce.html`; + return this.mappingCoerce; } public getBoostLink() { - return `${this.esDocsBase}/mapping-boost.html`; + return this.mappingParameters; } public getNormalizerLink() { - return `${this.esDocsBase}/normalizer.html`; + return this.mappingNormalizer; } public getIgnoreAboveLink() { - return `${this.esDocsBase}/ignore-above.html`; + return this.mappingIgnoreAbove; } public getFielddataLink() { - return `${this.esDocsBase}/fielddata.html`; + return this.mappingFieldData; } public getFielddataFrequencyLink() { - return `${this.esDocsBase}/fielddata.html#field-data-filtering`; + return this.mappingFieldDataFilter; } public getEnablingFielddataLink() { - return `${this.esDocsBase}/fielddata.html#before-enabling-fielddata`; + return this.mappingFieldDataEnable; } public getIndexPhrasesLink() { - return `${this.esDocsBase}/index-phrases.html`; + return this.mappingIndexPhrases; } public getIndexPrefixesLink() { - return `${this.esDocsBase}/index-prefixes.html`; + return this.mappingIndexPrefixes; } public getPositionIncrementGapLink() { - return `${this.esDocsBase}/position-increment-gap.html`; + return this.mappingPositionIncrementGap; } public getAnalyzerLink() { - return `${this.esDocsBase}/analyzer.html`; + return this.mappingAnalyzer; } public getDateFormatLink() { - return `${this.esDocsBase}/mapping-date-format.html`; + return this.mappingFormat; } public getIndexOptionsLink() { - return `${this.esDocsBase}/index-options.html`; + return this.mappingIndexOptions; } public getAlternativeToMappingTypesLink() { - return `${this.esDocsBase}/removal-of-types.html#_alternatives_to_mapping_types`; + return this.mappingTypesRemoval; } public getJoinMultiLevelsPerformanceLink() { - return `${this.esDocsBase}/parent-join.html#_parent_join_and_performance`; + return this.mappingJoinFieldsPerformance; } public getDynamicLink() { - return `${this.esDocsBase}/dynamic.html`; + return this.mappingDynamic; } public getEnabledLink() { - return `${this.esDocsBase}/enabled.html`; + return this.mappingEnabled; } public getRuntimeFields() { - return `${this.esDocsBase}/runtime.html`; + return this.runtimeFields; } public getWellKnownTextLink() { From 8e9ca665206d838326b0f0fc264fac78f3080a42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20C=C3=B4t=C3=A9?= Date: Tue, 13 Apr 2021 13:29:22 -0400 Subject: [PATCH 42/90] Fix alerting flaky test by adding retryIfConflict to fixture APIs (#96226) * Add retryIfConflict to fixture APIs * Fix * Fix import errors? * Revert part of the fix * Attempt fix * Attempt 2 * Try again * Remove dependency on core code * Comment Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../fixtures/plugins/alerts/server/index.ts | 3 +- .../alerts/server/lib/retry_if_conflicts.ts | 64 +++++++++++++ .../fixtures/plugins/alerts/server/plugin.ts | 10 +- .../fixtures/plugins/alerts/server/routes.ts | 95 ++++++++++++------- 4 files changed, 135 insertions(+), 37 deletions(-) create mode 100644 x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/lib/retry_if_conflicts.ts diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/index.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/index.ts index 700aee6bfd49d6..027ea50a8ae6a2 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/index.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/index.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { PluginInitializerContext } from 'kibana/server'; import { FixturePlugin } from './plugin'; -export const plugin = () => new FixturePlugin(); +export const plugin = (initContext: PluginInitializerContext) => new FixturePlugin(initContext); diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/lib/retry_if_conflicts.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/lib/retry_if_conflicts.ts new file mode 100644 index 00000000000000..776686bcd1c0a3 --- /dev/null +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/lib/retry_if_conflicts.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// This module provides a helper to perform retries on a function if the +// function ends up throwing a SavedObject 409 conflict. This can happen +// when alert SO's are updated in the background, and will avoid having to +// have the caller make explicit conflict checks, where the conflict was +// caused by a background update. + +import { Logger } from 'kibana/server'; + +type RetryableForConflicts = () => Promise; + +// number of times to retry when conflicts occur +export const RetryForConflictsAttempts = 2; + +// milliseconds to wait before retrying when conflicts occur +// note: we considered making this random, to help avoid a stampede, but +// with 1 retry it probably doesn't matter, and adding randomness could +// make it harder to diagnose issues +const RetryForConflictsDelay = 250; + +// retry an operation if it runs into 409 Conflict's, up to a limit +export async function retryIfConflicts( + logger: Logger, + name: string, + operation: RetryableForConflicts, + retries: number = RetryForConflictsAttempts +): Promise { + // run the operation, return if no errors or throw if not a conflict error + try { + return await operation(); + } catch (err) { + if (!isConflictError(err)) { + throw err; + } + + // must be a conflict; if no retries left, throw it + if (retries <= 0) { + logger.warn(`${name} conflict, exceeded retries`); + throw err; + } + + // delay a bit before retrying + logger.debug(`${name} conflict, retrying ...`); + await waitBeforeNextRetry(); + return await retryIfConflicts(logger, name, operation, retries - 1); + } +} + +async function waitBeforeNextRetry(): Promise { + await new Promise((resolve) => setTimeout(resolve, RetryForConflictsDelay)); +} + +// This is a workaround to avoid having to add more code to compile for tests via +// packages/kbn-test/src/functional_tests/lib/babel_register_for_test_plugins.js +// to use SavedObjectsErrorHelpers.isConflictError. +function isConflictError(error: any): boolean { + return error.isBoom === true && error.output.statusCode === 409; +} diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/plugin.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/plugin.ts index 972cb05c997663..bf5d05ee4624a8 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/plugin.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { Plugin, CoreSetup } from 'kibana/server'; +import { Plugin, CoreSetup, Logger, PluginInitializerContext } from 'kibana/server'; import { PluginSetupContract as ActionsPluginSetup } from '../../../../../../../plugins/actions/server/plugin'; import { PluginSetupContract as AlertingPluginSetup } from '../../../../../../../plugins/alerting/server/plugin'; import { EncryptedSavedObjectsPluginStart } from '../../../../../../../plugins/encrypted_saved_objects/server'; @@ -29,6 +29,12 @@ export interface FixtureStartDeps { } export class FixturePlugin implements Plugin { + private readonly logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get('fixtures', 'plugins', 'alerts'); + } + public setup( core: CoreSetup, { features, actions, alerting }: FixtureSetupDeps @@ -109,7 +115,7 @@ export class FixturePlugin implements Plugin) { +export function defineRoutes(core: CoreSetup, { logger }: { logger: Logger }) { const router = core.http.createRouter(); router.put( { @@ -84,28 +86,35 @@ export function defineRoutes(core: CoreSetup) { throw new Error('Failed to grant an API Key'); } - const result = await savedObjectsWithAlerts.update( - 'alert', - id, - { - ...( - await encryptedSavedObjectsWithAlerts.getDecryptedAsInternalUser( - 'alert', - id, - { - namespace, - } - ) - ).attributes, - apiKey: Buffer.from(`${createAPIKeyResult.id}:${createAPIKeyResult.api_key}`).toString( - 'base64' - ), - apiKeyOwner: user.username, - }, - { - namespace, + const result = await retryIfConflicts( + logger, + `/api/alerts_fixture/${id}/replace_api_key`, + async () => { + return await savedObjectsWithAlerts.update( + 'alert', + id, + { + ...( + await encryptedSavedObjectsWithAlerts.getDecryptedAsInternalUser( + 'alert', + id, + { + namespace, + } + ) + ).attributes, + apiKey: Buffer.from( + `${createAPIKeyResult.id}:${createAPIKeyResult.api_key}` + ).toString('base64'), + apiKeyOwner: user.username, + }, + { + namespace, + } + ); } ); + return res.ok({ body: result }); } ); @@ -147,11 +156,17 @@ export function defineRoutes(core: CoreSetup) { includedHiddenTypes: ['alert'], }); const savedAlert = await savedObjectsWithAlerts.get(type, id); - const result = await savedObjectsWithAlerts.update( - type, - id, - { ...savedAlert.attributes, ...attributes }, - options + const result = await retryIfConflicts( + logger, + `/api/alerts_fixture/saved_object/${type}/${id}`, + async () => { + return await savedObjectsWithAlerts.update( + type, + id, + { ...savedAlert.attributes, ...attributes }, + options + ); + } ); return res.ok({ body: result }); } @@ -182,10 +197,16 @@ export function defineRoutes(core: CoreSetup) { includedHiddenTypes: ['task', 'alert'], }); const alert = await savedObjectsWithTasksAndAlerts.get('alert', id); - const result = await savedObjectsWithTasksAndAlerts.update( - 'task', - alert.attributes.scheduledTaskId!, - { runAt } + const result = await retryIfConflicts( + logger, + `/api/alerts_fixture/${id}/reschedule_task`, + async () => { + return await savedObjectsWithTasksAndAlerts.update( + 'task', + alert.attributes.scheduledTaskId!, + { runAt } + ); + } ); return res.ok({ body: result }); } @@ -216,10 +237,16 @@ export function defineRoutes(core: CoreSetup) { includedHiddenTypes: ['task', 'alert'], }); const alert = await savedObjectsWithTasksAndAlerts.get('alert', id); - const result = await savedObjectsWithTasksAndAlerts.update( - 'task', - alert.attributes.scheduledTaskId!, - { status } + const result = await retryIfConflicts( + logger, + `/api/alerts_fixture/{id}/reset_task_status`, + async () => { + return await savedObjectsWithTasksAndAlerts.update( + 'task', + alert.attributes.scheduledTaskId!, + { status } + ); + } ); return res.ok({ body: result }); } From 67e512fe276201c69402d26c8b748af87ed1f8bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Tue, 13 Apr 2021 18:47:20 +0100 Subject: [PATCH 43/90] [ILM] Add UI validation for min age value (#96718) --- .../src/jest/utils/testbed/testbed.ts | 16 ++- .../kbn-test/src/jest/utils/testbed/types.ts | 2 +- .../edit_policy/constants.ts | 4 + .../edit_policy/edit_policy.helpers.tsx | 6 +- .../features/searchable_snapshots.test.ts | 5 + .../form_validation/error_indicators.test.ts | 11 +- .../form_validation/timing.test.ts | 52 ++++++++ .../policy_serialization.test.ts | 15 ++- .../components/form_errors_callout.tsx | 3 +- .../min_age_field/min_age_field.tsx | 48 +++++-- .../sections/edit_policy/form/deserializer.ts | 4 + .../edit_policy/form/form_errors_context.tsx | 8 +- .../form/global_fields_context.tsx | 16 +++ .../sections/edit_policy/form/schema.ts | 44 ++++++- .../sections/edit_policy/form/validations.ts | 117 +++++++++++++++++- .../lib/absolute_timing_to_relative_timing.ts | 13 +- .../sections/edit_policy/lib/index.ts | 1 + .../application/sections/edit_policy/types.ts | 3 + .../index_lifecycle_management_page.ts | 20 ++- 19 files changed, 341 insertions(+), 47 deletions(-) diff --git a/packages/kbn-test/src/jest/utils/testbed/testbed.ts b/packages/kbn-test/src/jest/utils/testbed/testbed.ts index edb040db8186c2..472b9f2df939cf 100644 --- a/packages/kbn-test/src/jest/utils/testbed/testbed.ts +++ b/packages/kbn-test/src/jest/utils/testbed/testbed.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import { ComponentType, ReactWrapper } from 'enzyme'; +import { Component as ReactComponent } from 'react'; +import { ComponentType, HTMLAttributes, ReactWrapper } from 'enzyme'; import { findTestSubject } from '../find_test_subject'; import { reactRouterMock } from '../router_helpers'; @@ -250,8 +251,17 @@ export const registerTestBed = ( component.update(); }; - const getErrorsMessages: TestBed['form']['getErrorsMessages'] = () => { - const errorMessagesWrappers = component.find('.euiFormErrorText'); + const getErrorsMessages: TestBed['form']['getErrorsMessages'] = ( + wrapper?: T | ReactWrapper + ) => { + let errorMessagesWrappers: ReactWrapper; + if (typeof wrapper === 'string') { + errorMessagesWrappers = find(wrapper).find('.euiFormErrorText'); + } else { + errorMessagesWrappers = wrapper + ? wrapper.find('.euiFormErrorText') + : component.find('.euiFormErrorText'); + } return errorMessagesWrappers.map((err) => err.text()); }; diff --git a/packages/kbn-test/src/jest/utils/testbed/types.ts b/packages/kbn-test/src/jest/utils/testbed/types.ts index 338794869d9b18..520a78d03d7013 100644 --- a/packages/kbn-test/src/jest/utils/testbed/types.ts +++ b/packages/kbn-test/src/jest/utils/testbed/types.ts @@ -133,7 +133,7 @@ export interface TestBed { /** * Get a list of the form error messages that are visible in the DOM. */ - getErrorsMessages: () => string[]; + getErrorsMessages: (wrapper?: T | ReactWrapper) => string[]; }; table: { getMetaData: (tableTestSubject: T) => EuiTableMetaData; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts index e47036b82e5941..2c84acc9694960 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts @@ -29,6 +29,7 @@ export const POLICY_WITH_MIGRATE_OFF: PolicyFromES = { }, }, warm: { + min_age: '1d', actions: { migrate: { enabled: false }, }, @@ -54,6 +55,7 @@ export const POLICY_WITH_INCLUDE_EXCLUDE: PolicyFromES = { }, }, warm: { + min_age: '10d', actions: { allocate: { include: { @@ -196,6 +198,7 @@ export const POLICY_WITH_KNOWN_AND_UNKNOWN_FIELDS = ({ }, }, warm: { + min_age: '10d', actions: { my_unfollow_action: {}, set_priority: { @@ -205,6 +208,7 @@ export const POLICY_WITH_KNOWN_AND_UNKNOWN_FIELDS = ({ }, }, delete: { + min_age: '15d', wait_for_snapshot: { policy: SNAPSHOT_POLICY_NAME, }, 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 12de34b79ee12d..6e4dbd90082a4b 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 @@ -320,10 +320,8 @@ export const setup = async (arg?: { }; /* - * For new we rely on a setTimeout to ensure that error messages have time to populate - * the form object before we look at the form object. See: - * x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/form_errors_context.tsx - * for where this logic lives. + * We rely on a setTimeout (dedounce) to display error messages under the form fields. + * This handler runs all the timers so we can assert for errors in our tests. */ const runTimers = () => { act(() => { diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/searchable_snapshots.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/searchable_snapshots.test.ts index e21793e650683a..ede40521deb97d 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/searchable_snapshots.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/searchable_snapshots.test.ts @@ -77,8 +77,10 @@ describe(' searchable snapshots', () => { const repository = 'myRepo'; await actions.hot.setSearchableSnapshot(repository); await actions.cold.enable(true); + await actions.cold.setMinAgeValue('10'); await actions.cold.toggleSearchableSnapshot(true); await actions.frozen.enable(true); + await actions.frozen.setMinAgeValue('15'); await actions.savePolicy(); const latestRequest = server.requests[server.requests.length - 1]; @@ -96,8 +98,10 @@ describe(' searchable snapshots', () => { await actions.hot.setSearchableSnapshot('myRepo'); await actions.cold.enable(true); + await actions.cold.setMinAgeValue('10'); await actions.cold.toggleSearchableSnapshot(true); await actions.frozen.enable(true); + await actions.frozen.setMinAgeValue('15'); // We update the repository in one phase await actions.frozen.setSearchableSnapshot('changed'); @@ -161,6 +165,7 @@ describe(' searchable snapshots', () => { test('correctly sets snapshot repository default to "found-snapshots"', async () => { const { actions } = testBed; await actions.cold.enable(true); + await actions.cold.setMinAgeValue('10'); await actions.cold.toggleSearchableSnapshot(true); await actions.savePolicy(); const latestRequest = server.requests[server.requests.length - 1]; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/form_validation/error_indicators.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/form_validation/error_indicators.test.ts index e2d937cf9c8dbb..86cf4ab5a48580 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/form_validation/error_indicators.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/form_validation/error_indicators.test.ts @@ -56,7 +56,6 @@ describe(' error indicators', () => { const { actions } = testBed; // 0. No validation issues - expect(actions.hasGlobalErrorCallout()).toBe(false); expect(actions.hot.hasErrorIndicator()).toBe(false); expect(actions.warm.hasErrorIndicator()).toBe(false); expect(actions.cold.hasErrorIndicator()).toBe(false); @@ -65,7 +64,6 @@ describe(' error indicators', () => { await actions.hot.toggleForceMerge(true); await actions.hot.setForcemergeSegmentsCount('-22'); runTimers(); - expect(actions.hasGlobalErrorCallout()).toBe(true); expect(actions.hot.hasErrorIndicator()).toBe(true); expect(actions.warm.hasErrorIndicator()).toBe(false); expect(actions.cold.hasErrorIndicator()).toBe(false); @@ -75,7 +73,6 @@ describe(' error indicators', () => { await actions.warm.toggleForceMerge(true); await actions.warm.setForcemergeSegmentsCount('-22'); runTimers(); - expect(actions.hasGlobalErrorCallout()).toBe(true); expect(actions.hot.hasErrorIndicator()).toBe(true); expect(actions.warm.hasErrorIndicator()).toBe(true); expect(actions.cold.hasErrorIndicator()).toBe(false); @@ -84,7 +81,6 @@ describe(' error indicators', () => { await actions.cold.enable(true); await actions.cold.setReplicas('-33'); runTimers(); - expect(actions.hasGlobalErrorCallout()).toBe(true); expect(actions.hot.hasErrorIndicator()).toBe(true); expect(actions.warm.hasErrorIndicator()).toBe(true); expect(actions.cold.hasErrorIndicator()).toBe(true); @@ -92,7 +88,6 @@ describe(' error indicators', () => { // 4. Fix validation issue in hot await actions.hot.setForcemergeSegmentsCount('1'); runTimers(); - expect(actions.hasGlobalErrorCallout()).toBe(true); expect(actions.hot.hasErrorIndicator()).toBe(false); expect(actions.warm.hasErrorIndicator()).toBe(true); expect(actions.cold.hasErrorIndicator()).toBe(true); @@ -100,7 +95,6 @@ describe(' error indicators', () => { // 5. Fix validation issue in warm await actions.warm.setForcemergeSegmentsCount('1'); runTimers(); - expect(actions.hasGlobalErrorCallout()).toBe(true); expect(actions.hot.hasErrorIndicator()).toBe(false); expect(actions.warm.hasErrorIndicator()).toBe(false); expect(actions.cold.hasErrorIndicator()).toBe(true); @@ -108,13 +102,12 @@ describe(' error indicators', () => { // 6. Fix validation issue in cold await actions.cold.setReplicas('1'); runTimers(); - expect(actions.hasGlobalErrorCallout()).toBe(false); expect(actions.hot.hasErrorIndicator()).toBe(false); expect(actions.warm.hasErrorIndicator()).toBe(false); expect(actions.cold.hasErrorIndicator()).toBe(false); }); - test('global error callout should show if there are any form errors', async () => { + test('global error callout should show, after clicking the "Save" button, if there are any form errors', async () => { const { actions } = testBed; expect(actions.hasGlobalErrorCallout()).toBe(false); @@ -125,6 +118,7 @@ describe(' error indicators', () => { await actions.saveAsNewPolicy(true); await actions.setPolicyName(''); runTimers(); + await actions.savePolicy(); expect(actions.hasGlobalErrorCallout()).toBe(true); expect(actions.hot.hasErrorIndicator()).toBe(false); @@ -136,6 +130,7 @@ describe(' error indicators', () => { const { actions } = testBed; await actions.cold.enable(true); + await actions.cold.setMinAgeValue('7'); // introduce validation error await actions.cold.setSearchableSnapshot(''); runTimers(); diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/form_validation/timing.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/form_validation/timing.test.ts index 52009902ab8021..c0b30efe150c4f 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/form_validation/timing.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/form_validation/timing.test.ts @@ -81,6 +81,10 @@ describe(' timing validation', () => { test(`${phase}: ${name}`, async () => { const { actions } = testBed; await actions[phase as 'warm' | 'cold' | 'delete' | 'frozen'].enable(true); + // 1. We first set as dummy value to have a starting min_age value + await actions[phase as 'warm' | 'cold' | 'delete' | 'frozen'].setMinAgeValue('111'); + // 2. At this point we are sure there will be a change of value and that any validation + // will be displayed under the field. await actions[phase as 'warm' | 'cold' | 'delete' | 'frozen'].setMinAgeValue(value); runTimers(); @@ -89,4 +93,52 @@ describe(' timing validation', () => { }); }); }); + + test('should validate that min_age is equal or greater than previous phase min_age', async () => { + const { actions, form } = testBed; + await actions.warm.enable(true); + await actions.cold.enable(true); + await actions.frozen.enable(true); + await actions.delete.enable(true); + + await actions.warm.setMinAgeValue('10'); + + await actions.cold.setMinAgeValue('9'); + runTimers(); + expect(form.getErrorsMessages('cold-phase')).toEqual([ + 'Must be greater or equal than the warm phase value (10d)', + ]); + + await actions.frozen.setMinAgeValue('8'); + runTimers(); + expect(form.getErrorsMessages('frozen-phase')).toEqual([ + 'Must be greater or equal than the cold phase value (9d)', + ]); + + await actions.delete.setMinAgeValue('7'); + runTimers(); + expect(form.getErrorsMessages('delete-phaseContent')).toEqual([ + 'Must be greater or equal than the frozen phase value (8d)', + ]); + + // Disable the warm phase + await actions.warm.enable(false); + + // No more error for the cold phase + expect(form.getErrorsMessages('cold-phase')).toEqual([]); + + // Change to smaller unit for cold phase + await actions.cold.setMinAgeUnits('h'); + + // No more error for the frozen phase... + expect(form.getErrorsMessages('frozen-phase')).toEqual([]); + // ...but the delete phase has still the error + expect(form.getErrorsMessages('delete-phaseContent')).toEqual([ + 'Must be greater or equal than the frozen phase value (8d)', + ]); + + await actions.delete.setMinAgeValue('9'); + // No more error for the delete phase + expect(form.getErrorsMessages('delete-phaseContent')).toEqual([]); + }); }); diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/serialization/policy_serialization.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/serialization/policy_serialization.test.ts index aa176fe3b188fb..7a0571e4a7cb2b 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/serialization/policy_serialization.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/serialization/policy_serialization.test.ts @@ -87,7 +87,7 @@ describe(' serialization', () => { unknown_setting: true, }, }, - min_age: '0d', + min_age: '10d', }, }, }); @@ -264,6 +264,7 @@ describe(' serialization', () => { test('default values', async () => { const { actions } = testBed; await actions.warm.enable(true); + await actions.warm.setMinAgeValue('11'); await actions.savePolicy(); const latestRequest = server.requests[server.requests.length - 1]; const warmPhase = JSON.parse(JSON.parse(latestRequest.requestBody).body).phases.warm; @@ -274,7 +275,7 @@ describe(' serialization', () => { "priority": 50, }, }, - "min_age": "0d", + "min_age": "11d", } `); }); @@ -282,6 +283,7 @@ describe(' serialization', () => { test('setting all values', async () => { const { actions } = testBed; await actions.warm.enable(true); + await actions.warm.setMinAgeValue('11'); await actions.warm.setDataAllocation('node_attrs'); await actions.warm.setSelectedNodeAttribute('test:123'); await actions.warm.setReplicas('123'); @@ -329,7 +331,7 @@ describe(' serialization', () => { "number_of_shards": 123, }, }, - "min_age": "0d", + "min_age": "11d", }, }, } @@ -401,6 +403,7 @@ describe(' serialization', () => { const { actions } = testBed; await actions.cold.enable(true); + await actions.cold.setMinAgeValue('11'); await actions.savePolicy(); const latestRequest = server.requests[server.requests.length - 1]; const entirePolicy = JSON.parse(JSON.parse(latestRequest.requestBody).body); @@ -411,7 +414,7 @@ describe(' serialization', () => { "priority": 0, }, }, - "min_age": "0d", + "min_age": "11d", } `); }); @@ -471,6 +474,7 @@ describe(' serialization', () => { test('setting searchable snapshot', async () => { const { actions } = testBed; await actions.cold.enable(true); + await actions.cold.setMinAgeValue('10'); await actions.cold.setSearchableSnapshot('my-repo'); await actions.savePolicy(); const latestRequest2 = server.requests[server.requests.length - 1]; @@ -485,6 +489,7 @@ describe(' serialization', () => { test('default value', async () => { const { actions } = testBed; await actions.frozen.enable(true); + await actions.frozen.setMinAgeValue('13'); await actions.frozen.setSearchableSnapshot('myRepo'); await actions.savePolicy(); @@ -492,7 +497,7 @@ describe(' serialization', () => { const latestRequest = server.requests[server.requests.length - 1]; const entirePolicy = JSON.parse(JSON.parse(latestRequest.requestBody).body); expect(entirePolicy.phases.frozen).toEqual({ - min_age: '0d', + min_age: '13d', actions: { searchable_snapshot: { snapshot_repository: 'myRepo' }, }, diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/form_errors_callout.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/form_errors_callout.tsx index b72ec1df2f26b3..478d1af69f81ce 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/form_errors_callout.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/form_errors_callout.tsx @@ -25,9 +25,10 @@ const i18nTexts = { export const FormErrorsCallout: FunctionComponent = () => { const { errors: { hasErrors }, + isFormSubmitted, } = useFormErrorsContext(); - if (!hasErrors) { + if (!isFormSubmitted || !hasErrors) { return null; } diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx index 3fe2f08cb4066e..136a37140cca7c 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx @@ -6,8 +6,9 @@ */ import { i18n } from '@kbn/i18n'; -import React, { FunctionComponent } from 'react'; +import React, { FunctionComponent, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; +import { get } from 'lodash'; import { EuiFieldNumber, @@ -20,10 +21,9 @@ import { EuiIconTip, } from '@elastic/eui'; -import { getFieldValidityAndErrorMessage } from '../../../../../../../shared_imports'; - -import { UseField, useConfiguration } from '../../../../form'; - +import { getFieldValidityAndErrorMessage, useFormData } from '../../../../../../../shared_imports'; +import { UseField, useConfiguration, useGlobalFields } from '../../../../form'; +import { getPhaseMinAgeInMilliseconds } from '../../../../lib'; import { getUnitsAriaLabelForPhase, getTimingLabelForPhase } from './util'; type PhaseWithMinAgeAction = 'warm' | 'cold' | 'delete'; @@ -81,9 +81,43 @@ interface Props { } export const MinAgeField: FunctionComponent = ({ phase }): React.ReactElement => { + const minAgeValuePath = `phases.${phase}.min_age`; + const minAgeUnitPath = `_meta.${phase}.minAgeUnit`; + const { isUsingRollover } = useConfiguration(); + const globalFields = useGlobalFields(); + + const { setValue: setMillisecondValue } = globalFields[ + `${phase}MinAgeMilliSeconds` as 'coldMinAgeMilliSeconds' + ]; + const [formData] = useFormData({ watch: [minAgeValuePath, minAgeUnitPath] }); + const minAgeValue = get(formData, minAgeValuePath); + const minAgeUnit = get(formData, minAgeUnitPath); + + useEffect(() => { + // Whenever the min_age value of the field OR the min_age unit + // changes, we update the corresponding millisecond global field for the phase + if (minAgeValue === undefined) { + return; + } + + const milliseconds = + minAgeValue.trim() === '' ? -1 : getPhaseMinAgeInMilliseconds(minAgeValue, minAgeUnit); + + setMillisecondValue(milliseconds); + }, [minAgeValue, minAgeUnit, setMillisecondValue]); + + useEffect(() => { + return () => { + // When unmounting (meaning we have disabled the phase), we remove + // the millisecond value so the next time we enable the phase it will + // be updated and trigger the validation + setMillisecondValue(-1); + }; + }, [setMillisecondValue]); + return ( - + {(field) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); return ( @@ -118,7 +152,7 @@ export const MinAgeField: FunctionComponent = ({ phase }): React.ReactEle /> - + {(unitField) => { const { isInvalid: isUnitFieldInvalid } = getFieldValidityAndErrorMessage( unitField diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts index af571d16ca8c5e..356a5b4561d0a6 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts @@ -46,20 +46,24 @@ export const createDeserializer = (isCloudEnabled: boolean) => ( bestCompression: warm?.actions?.forcemerge?.index_codec === 'best_compression', dataTierAllocationType: determineDataTierAllocationType(warm?.actions), readonlyEnabled: Boolean(warm?.actions?.readonly), + minAgeToMilliSeconds: -1, }, cold: { enabled: Boolean(cold), dataTierAllocationType: determineDataTierAllocationType(cold?.actions), freezeEnabled: Boolean(cold?.actions?.freeze), readonlyEnabled: Boolean(cold?.actions?.readonly), + minAgeToMilliSeconds: -1, }, frozen: { enabled: Boolean(frozen), dataTierAllocationType: determineDataTierAllocationType(frozen?.actions), freezeEnabled: Boolean(frozen?.actions?.freeze), + minAgeToMilliSeconds: -1, }, delete: { enabled: Boolean(deletePhase), + minAgeToMilliSeconds: -1, }, searchableSnapshot: { repository: defaultRepository, diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/form_errors_context.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/form_errors_context.tsx index b4aab0ffdea600..70199e08aa3084 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/form_errors_context.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/form_errors_context.tsx @@ -38,6 +38,7 @@ interface ContextValue { errors: Errors; addError(phase: PhasesAndOther, fieldPath: string, errorMessages: string[]): void; clearError(phase: PhasesAndOther, fieldPath: string): void; + isFormSubmitted: boolean; } const FormErrorsContext = createContext(null as any); @@ -56,7 +57,7 @@ export const FormErrorsProvider: FunctionComponent = ({ children }) => { const [errors, setErrors] = useState(createEmptyErrors); const form = useFormContext(); - const { getErrors: getFormErrors } = form; + const { getErrors: getFormErrors, isSubmitted } = form; const addError: ContextValue['addError'] = useCallback( (phase, fieldPath, errorMessages) => { @@ -83,9 +84,9 @@ export const FormErrorsProvider: FunctionComponent = ({ children }) => { } = previousErrors; const nextHasErrors = - Object.keys(restOfPhaseErrors).length === 0 && + Object.keys(restOfPhaseErrors).length > 0 || Object.values(otherPhases).some((phaseErrors) => { - return !!Object.keys(phaseErrors).length; + return Object.keys(phaseErrors).length > 0; }); return { @@ -107,6 +108,7 @@ export const FormErrorsProvider: FunctionComponent = ({ children }) => { errors, addError, clearError, + isFormSubmitted: isSubmitted, }} > {children} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/global_fields_context.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/global_fields_context.tsx index 30a00390a18cca..94b804c1ce5324 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/global_fields_context.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/global_fields_context.tsx @@ -14,6 +14,10 @@ import { UseMultiFields, FieldHook, FieldConfig } from '../../../../shared_impor interface GlobalFieldsTypes { deleteEnabled: boolean; searchableSnapshotRepo: string; + warmMinAgeMilliSeconds: number; + coldMinAgeMilliSeconds: number; + frozenMinAgeMilliSeconds: number; + deleteMinAgeMilliSeconds: number; } type GlobalFields = { @@ -32,6 +36,18 @@ export const globalFields: Record< searchableSnapshotRepo: { path: '_meta.searchableSnapshot.repository', }, + warmMinAgeMilliSeconds: { + path: '_meta.warm.minAgeToMilliSeconds', + }, + coldMinAgeMilliSeconds: { + path: '_meta.cold.minAgeToMilliSeconds', + }, + frozenMinAgeMilliSeconds: { + path: '_meta.frozen.minAgeToMilliSeconds', + }, + deleteMinAgeMilliSeconds: { + path: '_meta.delete.minAgeToMilliSeconds', + }, }; export const GlobalFieldsProvider: FunctionComponent = ({ children }) => { 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 index ce7b36d69a32e7..93af58644cc060 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 @@ -10,12 +10,14 @@ import { i18n } from '@kbn/i18n'; import { FormSchema, fieldValidators } from '../../../../shared_imports'; import { defaultIndexPriority } from '../../../constants'; import { ROLLOVER_FORM_PATHS, CLOUD_DEFAULT_REPO } from '../constants'; +import { MinAgePhase } from '../types'; import { i18nTexts } from '../i18n_texts'; import { ifExistsNumberGreaterThanZero, ifExistsNumberNonNegative, rolloverThresholdsValidator, integerValidator, + minAgeGreaterThanPreviousPhase, } from './validations'; const rolloverFormPaths = Object.values(ROLLOVER_FORM_PATHS); @@ -117,8 +119,11 @@ const getPriorityField = (phase: 'hot' | 'warm' | 'cold' | 'frozen') => ({ serializer: serializers.stringToNumber, }); -const getMinAgeField = (defaultValue: string = '0') => ({ +const getMinAgeField = (phase: MinAgePhase, defaultValue?: string) => ({ defaultValue, + // By passing an empty array we make sure to *not* trigger the validation when the field value changes. + // The validation will be triggered when the millisecond variant (in the _meta) is updated (in sync) + fieldsToValidateOnChange: [], validations: [ { validator: emptyField(i18nTexts.editPolicy.errors.numberRequired), @@ -129,8 +134,12 @@ const getMinAgeField = (defaultValue: string = '0') => ({ { validator: integerValidator, }, + { + validator: minAgeGreaterThanPreviousPhase(phase), + }, ], }); + export const getSchema = (isCloudEnabled: boolean): FormSchema => ({ _meta: { hot: { @@ -173,6 +182,15 @@ export const getSchema = (isCloudEnabled: boolean): FormSchema => ({ minAgeUnit: { defaultValue: 'd', }, + minAgeToMilliSeconds: { + defaultValue: -1, + fieldsToValidateOnChange: [ + 'phases.warm.min_age', + 'phases.cold.min_age', + 'phases.frozen.min_age', + 'phases.delete.min_age', + ], + }, bestCompression: { label: i18nTexts.editPolicy.bestCompressionFieldLabel, }, @@ -208,6 +226,14 @@ export const getSchema = (isCloudEnabled: boolean): FormSchema => ({ minAgeUnit: { defaultValue: 'd', }, + minAgeToMilliSeconds: { + defaultValue: -1, + fieldsToValidateOnChange: [ + 'phases.cold.min_age', + 'phases.frozen.min_age', + 'phases.delete.min_age', + ], + }, dataTierAllocationType: { label: i18nTexts.editPolicy.allocationTypeOptionsFieldLabel, }, @@ -232,6 +258,10 @@ export const getSchema = (isCloudEnabled: boolean): FormSchema => ({ minAgeUnit: { defaultValue: 'd', }, + minAgeToMilliSeconds: { + defaultValue: -1, + fieldsToValidateOnChange: ['phases.frozen.min_age', 'phases.delete.min_age'], + }, dataTierAllocationType: { label: i18nTexts.editPolicy.allocationTypeOptionsFieldLabel, }, @@ -250,6 +280,10 @@ export const getSchema = (isCloudEnabled: boolean): FormSchema => ({ minAgeUnit: { defaultValue: 'd', }, + minAgeToMilliSeconds: { + defaultValue: -1, + fieldsToValidateOnChange: ['phases.delete.min_age'], + }, }, searchableSnapshot: { repository: { @@ -324,7 +358,7 @@ export const getSchema = (isCloudEnabled: boolean): FormSchema => ({ }, }, warm: { - min_age: getMinAgeField(), + min_age: getMinAgeField('warm'), actions: { allocate: { number_of_replicas: numberOfReplicasField, @@ -341,7 +375,7 @@ export const getSchema = (isCloudEnabled: boolean): FormSchema => ({ }, }, cold: { - min_age: getMinAgeField(), + min_age: getMinAgeField('cold'), actions: { allocate: { number_of_replicas: numberOfReplicasField, @@ -353,7 +387,7 @@ export const getSchema = (isCloudEnabled: boolean): FormSchema => ({ }, }, frozen: { - min_age: getMinAgeField(), + min_age: getMinAgeField('frozen'), actions: { allocate: { number_of_replicas: numberOfReplicasField, @@ -365,7 +399,7 @@ export const getSchema = (isCloudEnabled: boolean): FormSchema => ({ }, }, delete: { - min_age: getMinAgeField('365'), + min_age: getMinAgeField('delete', '365'), actions: { wait_for_snapshot: { 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 index ce85913d5db749..70a58ad1441926 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,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { i18n } from '@kbn/i18n'; import { fieldValidators, ValidationFunc, ValidationConfig } from '../../../../shared_imports'; @@ -11,7 +12,7 @@ import { ROLLOVER_FORM_PATHS } from '../constants'; import { i18nTexts } from '../i18n_texts'; import { PolicyFromES } from '../../../../../common/types'; -import { FormInternal } from '../types'; +import { FormInternal, MinAgePhase } from '../types'; const { numberGreaterThanField, containsCharsField, emptyField, startsWithField } = fieldValidators; @@ -149,3 +150,117 @@ export const createPolicyNameValidations = ({ }, ]; }; + +/** + * This validator guarantees that the user does not specify a min_age + * value smaller that the min_age of a previous phase. + * For example, the user can't define '5 days' for cold phase if the + * warm phase is set to '10 days'. + */ +export const minAgeGreaterThanPreviousPhase = (phase: MinAgePhase) => ({ + formData, +}: { + formData: Record; +}) => { + if (phase === 'warm') { + return; + } + + const getValueFor = (_phase: MinAgePhase) => { + const milli = formData[`_meta.${_phase}.minAgeToMilliSeconds`]; + + const esFormat = + milli >= 0 + ? formData[`phases.${_phase}.min_age`] + formData[`_meta.${_phase}.minAgeUnit`] + : undefined; + + return { + milli, + esFormat, + }; + }; + + const minAgeValues = { + warm: getValueFor('warm'), + cold: getValueFor('cold'), + frozen: getValueFor('frozen'), + delete: getValueFor('delete'), + }; + + const i18nErrors = { + greaterThanWarmPhase: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.minAgeSmallerThanWarmPhaseError', + { + defaultMessage: 'Must be greater or equal than the warm phase value ({value})', + values: { + value: minAgeValues.warm.esFormat, + }, + } + ), + greaterThanColdPhase: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.minAgeSmallerThanColdPhaseError', + { + defaultMessage: 'Must be greater or equal than the cold phase value ({value})', + values: { + value: minAgeValues.cold.esFormat, + }, + } + ), + greaterThanFrozenPhase: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.minAgeSmallerThanFrozenPhaseError', + { + defaultMessage: 'Must be greater or equal than the frozen phase value ({value})', + values: { + value: minAgeValues.frozen.esFormat, + }, + } + ), + }; + + if (phase === 'cold') { + if (minAgeValues.warm.milli >= 0 && minAgeValues.cold.milli < minAgeValues.warm.milli) { + return { + message: i18nErrors.greaterThanWarmPhase, + }; + } + return; + } + + if (phase === 'frozen') { + if (minAgeValues.cold.milli >= 0 && minAgeValues.frozen.milli < minAgeValues.cold.milli) { + return { + message: i18nErrors.greaterThanColdPhase, + }; + } else if ( + minAgeValues.warm.milli >= 0 && + minAgeValues.frozen.milli < minAgeValues.warm.milli + ) { + return { + message: i18nErrors.greaterThanWarmPhase, + }; + } + return; + } + + if (phase === 'delete') { + if (minAgeValues.frozen.milli >= 0 && minAgeValues.delete.milli < minAgeValues.frozen.milli) { + return { + message: i18nErrors.greaterThanFrozenPhase, + }; + } else if ( + minAgeValues.cold.milli >= 0 && + minAgeValues.delete.milli < minAgeValues.cold.milli + ) { + return { + message: i18nErrors.greaterThanColdPhase, + }; + } else if ( + minAgeValues.warm.milli >= 0 && + minAgeValues.delete.milli < minAgeValues.warm.milli + ) { + return { + message: i18nErrors.greaterThanWarmPhase, + }; + } + } +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.ts index 5d71bc057966e2..9d55f542db4c47 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.ts @@ -24,12 +24,10 @@ import moment from 'moment'; import { splitSizeAndUnits } from '../../../lib/policies'; -import { FormInternal } from '../types'; +import { FormInternal, MinAgePhase } from '../types'; /* -===- Private functions and types -===- */ -type MinAgePhase = 'warm' | 'cold' | 'frozen' | 'delete'; - type Phase = 'hot' | MinAgePhase; const phaseOrder: Phase[] = ['hot', 'warm', 'cold', 'frozen', 'delete']; @@ -44,9 +42,9 @@ const getMinAge = (phase: MinAgePhase, formData: FormInternal) => ({ * See https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#date-math * for all date math values. ILM policies also support "micros" and "nanos". */ -const getPhaseMinAgeInMilliseconds = (phase: { min_age: string }): number => { +export const getPhaseMinAgeInMilliseconds = (size: string, units: string): number => { let milliseconds: number; - const { units, size } = splitSizeAndUnits(phase.min_age); + if (units === 'micros') { milliseconds = parseInt(size, 10) / 1e3; } else if (units === 'nanos') { @@ -126,7 +124,10 @@ export const calculateRelativeFromAbsoluteMilliseconds = ( // If we have a next phase, calculate the timing between this phase and the next if (nextPhase && inputs[nextPhase]?.min_age) { - nextPhaseMinAge = getPhaseMinAgeInMilliseconds(inputs[nextPhase] as { min_age: string }); + const { units, size } = splitSizeAndUnits( + (inputs[nextPhase] as { min_age: string }).min_age + ); + nextPhaseMinAge = getPhaseMinAgeInMilliseconds(size, units); } return { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/index.ts index 19d87532f2bfe9..607c62cd3ce8be 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/index.ts @@ -8,6 +8,7 @@ export { calculateRelativeFromAbsoluteMilliseconds, formDataToAbsoluteTimings, + getPhaseMinAgeInMilliseconds, AbsoluteTimings, PhaseAgeInMilliseconds, RelativePhaseTimingInMs, 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 5cc631c5d95c0f..688d2ecfaa4a2c 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 @@ -15,8 +15,11 @@ export interface DataAllocationMetaFields { export interface MinAgeField { minAgeUnit?: string; + minAgeToMilliSeconds: number; } +export type MinAgePhase = 'warm' | 'cold' | 'frozen' | 'delete'; + export interface ForcemergeFields { bestCompression: boolean; } diff --git a/x-pack/test/functional/page_objects/index_lifecycle_management_page.ts b/x-pack/test/functional/page_objects/index_lifecycle_management_page.ts index f47e79260e61c8..525e0d91e2f4d8 100644 --- a/x-pack/test/functional/page_objects/index_lifecycle_management_page.ts +++ b/x-pack/test/functional/page_objects/index_lifecycle_management_page.ts @@ -22,18 +22,25 @@ export function IndexLifecycleManagementPageProvider({ getService }: FtrProvider policyName: string, warmEnabled: boolean = false, coldEnabled: boolean = false, - deletePhaseEnabled: boolean = false + deletePhaseEnabled: boolean = false, + minAges: { [key: string]: { value: string; unit: string } } = { + warm: { value: '10', unit: 'd' }, + cold: { value: '15', unit: 'd' }, + frozen: { value: '20', unit: 'd' }, + } ) { await testSubjects.setValue('policyNameField', policyName); if (warmEnabled) { await retry.try(async () => { await testSubjects.click('enablePhaseSwitch-warm'); }); + await testSubjects.setValue('warm-selectedMinimumAge', minAges.warm.value); } if (coldEnabled) { await retry.try(async () => { await testSubjects.click('enablePhaseSwitch-cold'); }); + await testSubjects.setValue('cold-selectedMinimumAge', minAges.cold.value); } if (deletePhaseEnabled) { await retry.try(async () => { @@ -48,10 +55,17 @@ export function IndexLifecycleManagementPageProvider({ getService }: FtrProvider policyName: string, warmEnabled: boolean = false, coldEnabled: boolean = false, - deletePhaseEnabled: boolean = false + deletePhaseEnabled: boolean = false, + minAges?: { [key: string]: { value: string; unit: string } } ) { await testSubjects.click('createPolicyButton'); - await this.fillNewPolicyForm(policyName, warmEnabled, coldEnabled, deletePhaseEnabled); + await this.fillNewPolicyForm( + policyName, + warmEnabled, + coldEnabled, + deletePhaseEnabled, + minAges + ); await this.saveNewPolicy(); }, From 47065acb053e3e4c6eee7f10a819938e5e2db52f Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 13 Apr 2021 19:14:06 +0100 Subject: [PATCH 44/90] chore(NA): moving @kbn/apm-utils into bazel (#96227) * chore(NA): moving @kbn/apm-utils into bazel * chore(NA): add kbn/apm-utils into package.json * chore(NA): missing standard on build file globs * chore(NA): be more explicit about incremental setting * chore(NA): include pretty in the args for ts_project rule * docs(NA): include package migration completion in the developer getting started Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../monorepo-packages.asciidoc | 1 + package.json | 2 +- packages/BUILD.bazel | 3 +- packages/elastic-datemath/BUILD.bazel | 6 +- packages/elastic-datemath/tsconfig.json | 1 + packages/kbn-apm-utils/BUILD.bazel | 76 +++++++++++++++++++ packages/kbn-apm-utils/package.json | 7 +- packages/kbn-apm-utils/tsconfig.json | 6 +- yarn.lock | 2 +- 9 files changed, 89 insertions(+), 15 deletions(-) create mode 100644 packages/kbn-apm-utils/BUILD.bazel diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index a95b357570278d..655a491f8b3ca5 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -62,5 +62,6 @@ yarn kbn watch-bazel === List of Already Migrated Packages to Bazel - @elastic/datemath +- @kbn/apm-utils diff --git a/package.json b/package.json index 9bddca46654674..c1f2a3b3cf1323 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,7 @@ "@kbn/ace": "link:packages/kbn-ace", "@kbn/analytics": "link:packages/kbn-analytics", "@kbn/apm-config-loader": "link:packages/kbn-apm-config-loader", - "@kbn/apm-utils": "link:packages/kbn-apm-utils", + "@kbn/apm-utils": "link:bazel-bin/packages/kbn-apm-utils/npm_module", "@kbn/config": "link:packages/kbn-config", "@kbn/config-schema": "link:packages/kbn-config-schema", "@kbn/crypto": "link:packages/kbn-crypto", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 31894fcb1bb5db..3944c2356badc7 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -3,6 +3,7 @@ filegroup( name = "build", srcs = [ - "//packages/elastic-datemath:build" + "//packages/elastic-datemath:build", + "//packages/kbn-apm-utils:build" ], ) diff --git a/packages/elastic-datemath/BUILD.bazel b/packages/elastic-datemath/BUILD.bazel index 6b9a725e91bd4d..bc0c1412ef5f15 100644 --- a/packages/elastic-datemath/BUILD.bazel +++ b/packages/elastic-datemath/BUILD.bazel @@ -4,15 +4,15 @@ load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") PKG_BASE_NAME = "elastic-datemath" PKG_REQUIRE_NAME = "@elastic/datemath" -SOURCE_FILES = [ +SOURCE_FILES = glob([ "src/index.ts", -] +]) SRCS = SOURCE_FILES filegroup( name = "srcs", - srcs = glob(SOURCE_FILES), + srcs = SRCS, ) NPM_MODULE_EXTRA_FILES = [ diff --git a/packages/elastic-datemath/tsconfig.json b/packages/elastic-datemath/tsconfig.json index d0fa806ed411b4..6e7219c7a82455 100644 --- a/packages/elastic-datemath/tsconfig.json +++ b/packages/elastic-datemath/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "declaration": true, "declarationMap": true, + "incremental": true, "outDir": "target", "rootDir": "src", "sourceMap": true, diff --git a/packages/kbn-apm-utils/BUILD.bazel b/packages/kbn-apm-utils/BUILD.bazel new file mode 100644 index 00000000000000..63adf2b77b5163 --- /dev/null +++ b/packages/kbn-apm-utils/BUILD.bazel @@ -0,0 +1,76 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-apm-utils" +PKG_REQUIRE_NAME = "@kbn/apm-utils" + +SOURCE_FILES = glob([ + "src/index.ts", +]) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", +] + +SRC_DEPS = [ + "@npm//elastic-apm-node", +] + +TYPES_DEPS = [ + "@npm//@types/node", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_map = True, + incremental = True, + out_dir = "target", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = [], + deps = [":tsc"] + DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + srcs = NPM_MODULE_EXTRA_FILES, + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-apm-utils/package.json b/packages/kbn-apm-utils/package.json index d414b94cb39789..04b8e2ed831b39 100644 --- a/packages/kbn-apm-utils/package.json +++ b/packages/kbn-apm-utils/package.json @@ -4,10 +4,5 @@ "types": "./target/index.d.ts", "version": "1.0.0", "license": "SSPL-1.0 OR Elastic License 2.0", - "private": true, - "scripts": { - "build": "../../node_modules/.bin/tsc", - "kbn:bootstrap": "yarn build", - "kbn:watch": "yarn build --watch" - } + "private": true } diff --git a/packages/kbn-apm-utils/tsconfig.json b/packages/kbn-apm-utils/tsconfig.json index e08769aab65436..3ce240059486a7 100644 --- a/packages/kbn-apm-utils/tsconfig.json +++ b/packages/kbn-apm-utils/tsconfig.json @@ -1,11 +1,11 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "incremental": false, - "outDir": "./target", - "stripInternal": false, "declaration": true, "declarationMap": true, + "incremental": true, + "outDir": "target", + "rootDir": "src", "sourceMap": true, "sourceRoot": "../../../../packages/kbn-apm-utils/src", "types": [ diff --git a/yarn.lock b/yarn.lock index 0e6427d2e265ed..559ad6e7f62f84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2616,7 +2616,7 @@ version "0.0.0" uid "" -"@kbn/apm-utils@link:packages/kbn-apm-utils": +"@kbn/apm-utils@link:bazel-bin/packages/kbn-apm-utils/npm_module": version "0.0.0" uid "" From 0500289699977d705d166ae41a95279722d95ca0 Mon Sep 17 00:00:00 2001 From: Spencer Date: Tue, 13 Apr 2021 11:35:14 -0700 Subject: [PATCH 45/90] [npm] upgrade caniuse database (#97002) Co-authored-by: spalger --- yarn.lock | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/yarn.lock b/yarn.lock index 559ad6e7f62f84..693da02fddfdf2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9186,20 +9186,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001181: - version "1.0.30001202" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001202.tgz#4cb3bd5e8a808e8cd89e4e66c549989bc8137201" - integrity sha512-ZcijQNqrcF8JNLjzvEiXqX4JUYxoZa7Pvcsd9UD8Kz4TvhTonOSNRsK+qtvpVL4l6+T1Rh4LFtLfnNWg6BGWCQ== - -caniuse-lite@^1.0.30001043, caniuse-lite@^1.0.30001097, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001125, caniuse-lite@^1.0.30001135, caniuse-lite@^1.0.30001173: - version "1.0.30001179" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001179.tgz" - integrity sha512-blMmO0QQujuUWZKyVrD1msR4WNDAqb/UPO1Sw2WWsQ7deoM5bJiicKnWJ1Y0NS/aGINSnKPIWBMw5luX+NDUCA== - -caniuse-lite@^1.0.30001157: - version "1.0.30001164" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001164.tgz#5bbfd64ca605d43132f13cc7fdabb17c3036bfdc" - integrity sha512-G+A/tkf4bu0dSp9+duNiXc7bGds35DioCyC6vgK2m/rjA4Krpy5WeZgZyfH2f0wj2kI6yAWWucyap6oOwmY1mg== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001043, caniuse-lite@^1.0.30001097, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001125, caniuse-lite@^1.0.30001135, caniuse-lite@^1.0.30001157, caniuse-lite@^1.0.30001173, caniuse-lite@^1.0.30001181: + version "1.0.30001208" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001208.tgz" + integrity sha512-OE5UE4+nBOro8Dyvv0lfx+SRtfVIOM9uhKqFmJeUbGriqhhStgp1A0OyBpgy3OUF8AhYCT+PVwPC1gMl2ZcQMA== capture-exit@^2.0.0: version "2.0.0" From d5bb7d6645103a028e0462524337f435f016fba5 Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Tue, 13 Apr 2021 13:49:25 -0500 Subject: [PATCH 46/90] Use `EuiThemeProvider` in lists plugin tests and stories (#96129) Remove `getMockTheme` and use `EuiThemeProvider` from the kibana_react plugin. Use the CSF-style decorators with `EuiThemeProvider` in the stories. No functional changes, but should be less code to maintain. --- .../common/test_utils/kibana_react.mock.ts | 13 ------ .../components/and_or_badge/index.stories.tsx | 20 ++++----- .../components/and_or_badge/index.test.tsx | 21 ++++----- .../rounded_badge_antenna.test.tsx | 17 +++---- .../components/builder/and_badge.test.tsx | 17 +++---- .../components/builder/builder.stories.tsx | 10 +---- .../builder/entry_renderer.stories.tsx | 19 ++++---- .../builder/exception_item_renderer.test.tsx | 24 ++++------ .../builder/exception_items_renderer.test.tsx | 44 ++++++++----------- 9 files changed, 71 insertions(+), 114 deletions(-) delete mode 100644 x-pack/plugins/lists/public/common/test_utils/kibana_react.mock.ts diff --git a/x-pack/plugins/lists/public/common/test_utils/kibana_react.mock.ts b/x-pack/plugins/lists/public/common/test_utils/kibana_react.mock.ts deleted file mode 100644 index 1516ca9128893b..00000000000000 --- a/x-pack/plugins/lists/public/common/test_utils/kibana_react.mock.ts +++ /dev/null @@ -1,13 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { RecursivePartial } from '@elastic/eui/src/components/common'; - -import { EuiTheme } from '../../../../../../src/plugins/kibana_react/common'; - -export const getMockTheme = (partialTheme: RecursivePartial): EuiTheme => - partialTheme as EuiTheme; diff --git a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.stories.tsx b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.stories.tsx index 8272ca9683a4f0..74ec0759b057ef 100644 --- a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.stories.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.stories.tsx @@ -5,26 +5,17 @@ * 2.0. */ -import { Story, addDecorator } from '@storybook/react'; +import { Story } from '@storybook/react'; import React from 'react'; -import { ThemeProvider } from 'styled-components'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; +import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; import { AndOrBadge, AndOrBadgeProps } from '.'; const sampleText = 'Doggo ipsum i am bekom fat snoot wow such tempt waggy wags floofs, ruff heckin good boys and girls mlem. Ruff heckin good boys and girls mlem stop it fren borkf borking doggo very hand that feed shibe, you are doing me the shock big ol heck smol borking doggo with a long snoot for pats heckin good boys. You are doing me the shock smol borking doggo with a long snoot for pats wow very biscit, length boy. Doggo ipsum i am bekom fat snoot wow such tempt waggy wags floofs, ruff heckin good boys and girls mlem. Ruff heckin good boys and girls mlem stop it fren borkf borking doggo very hand that feed shibe, you are doing me the shock big ol heck smol borking doggo with a long snoot for pats heckin good boys.'; -const mockTheme = getMockTheme({ - darkMode: false, - eui: euiLightVars, -}); - -addDecorator((storyFn) => {storyFn()}); - export default { argTypes: { includeAntennas: { @@ -58,6 +49,13 @@ export default { }, }, component: AndOrBadge, + decorators: [ + (DecoratorStory: React.ComponentClass): React.ReactNode => ( + + + + ), + ], title: 'AndOrBadge', }; diff --git a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.test.tsx b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.test.tsx index 47282d061a65de..26aa41549e61b3 100644 --- a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.test.tsx @@ -6,21 +6,18 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; +import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; import { AndOrBadge } from './'; -const mockTheme = getMockTheme({ eui: { euiColorLightShade: '#ece' } }); - describe('AndOrBadge', () => { test('it renders top and bottom antenna bars when "includeAntennas" is true', () => { const wrapper = mount( - + - + ); expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND'); @@ -30,9 +27,9 @@ describe('AndOrBadge', () => { test('it does not render top and bottom antenna bars when "includeAntennas" is false', () => { const wrapper = mount( - + - + ); expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('OR'); @@ -42,9 +39,9 @@ describe('AndOrBadge', () => { test('it renders "and" when "type" is "and"', () => { const wrapper = mount( - + - + ); expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND'); @@ -52,9 +49,9 @@ describe('AndOrBadge', () => { test('it renders "or" when "type" is "or"', () => { const wrapper = mount( - + - + ); expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('OR'); diff --git a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge_antenna.test.tsx b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge_antenna.test.tsx index 472345b9c9f191..dd5ed999dadcd3 100644 --- a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge_antenna.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge_antenna.test.tsx @@ -6,21 +6,18 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; +import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; import { RoundedBadgeAntenna } from './rounded_badge_antenna'; -const mockTheme = getMockTheme({ eui: { euiColorLightShade: '#ece' } }); - describe('RoundedBadgeAntenna', () => { test('it renders top and bottom antenna bars', () => { const wrapper = mount( - + - + ); expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND'); @@ -30,9 +27,9 @@ describe('RoundedBadgeAntenna', () => { test('it renders "and" when "type" is "and"', () => { const wrapper = mount( - + - + ); expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND'); @@ -40,9 +37,9 @@ describe('RoundedBadgeAntenna', () => { test('it renders "or" when "type" is "or"', () => { const wrapper = mount( - + - + ); expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('OR'); diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/and_badge.test.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/and_badge.test.tsx index dc773e222776b9..4a1471d9a3e5d1 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/and_badge.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/and_badge.test.tsx @@ -6,21 +6,18 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; +import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; import { BuilderAndBadgeComponent } from './and_badge'; -const mockTheme = getMockTheme({ eui: { euiColorLightShade: '#ece' } }); - describe('BuilderAndBadgeComponent', () => { test('it renders exceptionItemEntryFirstRowAndBadge for very first exception item in builder', () => { const wrapper = mount( - + - + ); expect( @@ -30,9 +27,9 @@ describe('BuilderAndBadgeComponent', () => { test('it renders exceptionItemEntryInvisibleAndBadge if "entriesLength" is 1 or less', () => { const wrapper = mount( - + - + ); expect( @@ -42,9 +39,9 @@ describe('BuilderAndBadgeComponent', () => { test('it renders regular "and" badge if exception item is not the first one and includes more than one entry', () => { const wrapper = mount( - + - + ); expect(wrapper.find('[data-test-subj="exceptionItemEntryAndBadge"]').exists()).toBeTruthy(); diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/builder.stories.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/builder.stories.tsx index 5199ead78ca0a0..8eaba9e82d7247 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/builder.stories.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/builder.stories.tsx @@ -13,16 +13,14 @@ import { Story, addDecorator } from '@storybook/react'; import React from 'react'; -import { ThemeProvider } from 'styled-components'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { HttpStart } from 'kibana/public'; import { AutocompleteStart } from '../../../../../../../src/plugins/data/public'; +import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; import { fields, getField, } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; -import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; import { getEntryMatchAnyMock } from '../../../../common/schemas/types/entry_match_any.mock'; import { getEntryMatchMock } from '../../../../common/schemas/types/entry_match.mock'; import { getEntryExistsMock } from '../../../../common/schemas/types/entry_exists.mock'; @@ -35,10 +33,6 @@ import { OnChangeProps, } from './exception_items_renderer'; -const mockTheme = getMockTheme({ - darkMode: false, - eui: euiLightVars, -}); const mockHttpService: HttpStart = ({ addLoadingCountSource: (): void => {}, anonymousPaths: { @@ -76,7 +70,7 @@ const mockAutocompleteService = ({ }), } as unknown) as AutocompleteStart; -addDecorator((storyFn) => {storyFn()}); +addDecorator((storyFn) => {storyFn()}); export default { argTypes: { diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.stories.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.stories.tsx index 8408fb7a6a4f1b..5b3730a6deb933 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.stories.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.stories.tsx @@ -5,24 +5,18 @@ * 2.0. */ -import { Story, addDecorator } from '@storybook/react'; +import { Story } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import React from 'react'; -import { ThemeProvider } from 'styled-components'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { HttpStart } from 'kibana/public'; import { OperatorEnum, OperatorTypeEnum } from '../../../../common'; import { AutocompleteStart } from '../../../../../../../src/plugins/data/public'; import { fields } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; -import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; +import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; import { BuilderEntryItem, EntryItemProps } from './entry_renderer'; -const mockTheme = getMockTheme({ - darkMode: false, - eui: euiLightVars, -}); const mockAutocompleteService = ({ getValueSuggestions: () => new Promise((resolve) => { @@ -59,8 +53,6 @@ const mockAutocompleteService = ({ }), } as unknown) as AutocompleteStart; -addDecorator((storyFn) => {storyFn()}); - export default { argTypes: { allowLargeValueLists: { @@ -163,6 +155,13 @@ export default { }, }, component: BuilderEntryItem, + decorators: [ + (DecoratorStory: React.ComponentClass): React.ReactNode => ( + + + + ), + ], title: 'BuilderEntryItem', }; diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.test.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.test.tsx index 0fd886bdc742a4..b896f2a44f67b3 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.test.tsx @@ -6,24 +6,18 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; import { dataPluginMock } from 'src/plugins/data/public/mocks'; import { fields } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; +import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; import { getExceptionListItemSchemaMock } from '../../../../common/schemas/response/exception_list_item_schema.mock'; import { getEntryMatchMock } from '../../../../common/schemas/types/entry_match.mock'; import { getEntryMatchAnyMock } from '../../../../common/schemas/types/entry_match_any.mock'; import { coreMock } from '../../../../../../../src/core/public/mocks'; -import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; import { BuilderExceptionListItemComponent } from './exception_item_renderer'; -const mockTheme = getMockTheme({ - eui: { - euiColorLightShade: '#ece', - }, -}); const mockKibanaHttpService = coreMock.createStart().http; const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract(); @@ -41,7 +35,7 @@ describe('BuilderExceptionListItemComponent', () => { entries: [getEntryMatchMock(), getEntryMatchMock()], }; const wrapper = mount( - + { onDeleteExceptionItem={jest.fn()} setErrorsExist={jest.fn()} /> - + ); expect( @@ -72,7 +66,7 @@ describe('BuilderExceptionListItemComponent', () => { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.entries = [getEntryMatchMock(), getEntryMatchMock()]; const wrapper = mount( - + { onDeleteExceptionItem={jest.fn()} setErrorsExist={jest.fn()} /> - + ); expect(wrapper.find('[data-test-subj="exceptionItemEntryAndBadge"]').exists()).toBeTruthy(); @@ -101,7 +95,7 @@ describe('BuilderExceptionListItemComponent', () => { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.entries = [getEntryMatchMock()]; const wrapper = mount( - + { onDeleteExceptionItem={jest.fn()} setErrorsExist={jest.fn()} /> - + ); expect( @@ -132,7 +126,7 @@ describe('BuilderExceptionListItemComponent', () => { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.entries = [getEntryMatchMock()]; const wrapper = mount( - + { onDeleteExceptionItem={jest.fn()} setErrorsExist={jest.fn()} /> - + ); expect( diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/exception_items_renderer.test.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/exception_items_renderer.test.tsx index b8ec8dc354bf84..a236b102eabf7b 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/exception_items_renderer.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/exception_items_renderer.test.tsx @@ -6,28 +6,22 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { ReactWrapper, mount } from 'enzyme'; import { waitFor } from '@testing-library/react'; import { coreMock } from 'src/core/public/mocks'; import { dataPluginMock } from 'src/plugins/data/public/mocks'; +import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; import { fields, getField, } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; import { getExceptionListItemSchemaMock } from '../../../../common/schemas/response/exception_list_item_schema.mock'; import { getEntryMatchAnyMock } from '../../../../common/schemas/types/entry_match_any.mock'; -import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; import { getEmptyValue } from '../../../common/empty_value'; import { ExceptionBuilderComponent } from './exception_items_renderer'; -const mockTheme = getMockTheme({ - eui: { - euiColorLightShade: '#ece', - }, -}); const mockKibanaHttpService = coreMock.createStart().http; const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract(); @@ -44,7 +38,7 @@ describe('ExceptionBuilderComponent', () => { test('it displays empty entry if no "exceptionListItems" are passed in', () => { wrapper = mount( - + { ruleName="Test rule" onChange={jest.fn()} /> - + ); expect(wrapper.find('EuiFlexGroup[data-test-subj="exceptionItemEntryContainer"]')).toHaveLength( @@ -83,7 +77,7 @@ describe('ExceptionBuilderComponent', () => { test('it displays "exceptionListItems" that are passed in', async () => { wrapper = mount( - + { ruleName="Test rule" onChange={jest.fn()} /> - + ); expect(wrapper.find('EuiFlexGroup[data-test-subj="exceptionItemEntryContainer"]')).toHaveLength( 1 @@ -128,7 +122,7 @@ describe('ExceptionBuilderComponent', () => { test('it displays "or", "and" and "add nested button" enabled', () => { wrapper = mount( - + { ruleName="Test rule" onChange={jest.fn()} /> - + ); expect( @@ -165,7 +159,7 @@ describe('ExceptionBuilderComponent', () => { test('it adds an entry when "and" clicked', async () => { wrapper = mount( - + { ruleName="Test rule" onChange={jest.fn()} /> - + ); expect(wrapper.find('EuiFlexGroup[data-test-subj="exceptionItemEntryContainer"]')).toHaveLength( @@ -222,7 +216,7 @@ describe('ExceptionBuilderComponent', () => { test('it adds an exception item when "or" clicked', async () => { wrapper = mount( - + { ruleName="Test rule" onChange={jest.fn()} /> - + ); expect(wrapper.find('EuiFlexGroup[data-test-subj="exceptionEntriesContainer"]')).toHaveLength( @@ -283,7 +277,7 @@ describe('ExceptionBuilderComponent', () => { test('it displays empty entry if user deletes last remaining entry', () => { wrapper = mount( - + { ruleName="Test rule" onChange={jest.fn()} /> - + ); expect(wrapper.find('[data-test-subj="exceptionBuilderEntryField"]').at(0).text()).toEqual( @@ -338,7 +332,7 @@ describe('ExceptionBuilderComponent', () => { test('it displays "and" badge if at least one exception item includes more than one entry', () => { wrapper = mount( - + { ruleName="Test rule" onChange={jest.fn()} /> - + ); expect( @@ -374,7 +368,7 @@ describe('ExceptionBuilderComponent', () => { test('it does not display "and" badge if none of the exception items include more than one entry', () => { wrapper = mount( - + { ruleName="Test rule" onChange={jest.fn()} /> - + ); wrapper.find('[data-test-subj="exceptionsOrButton"] button').simulate('click'); @@ -413,7 +407,7 @@ describe('ExceptionBuilderComponent', () => { describe('nested entry', () => { test('it adds a nested entry when "add nested entry" clicked', async () => { wrapper = mount( - + { ruleName="Test rule" onChange={jest.fn()} /> - + ); wrapper.find('[data-test-subj="exceptionsNestedButton"] button').simulate('click'); From d80c257f81d083be128a28131d9b1c820a6bf975 Mon Sep 17 00:00:00 2001 From: Matthew Kime Date: Tue, 13 Apr 2021 14:14:19 -0500 Subject: [PATCH 47/90] Index patterns server - throw correct error on field caps 404 (#95879) * throw correct error on field caps 404 and update tests --- .../index_patterns_api_client.ts | 24 ++++++++++++++----- .../index_patterns/index_patterns_service.ts | 2 +- .../fields_api/update_fields/main.ts | 3 ++- .../create_scripted_field/main.ts | 20 ++++++++++++---- .../delete_scripted_field/main.ts | 22 +++++++++++++---- .../get_scripted_field/main.ts | 14 ++++++++++- .../put_scripted_field/main.ts | 19 +++++++++++++-- .../update_scripted_field/main.ts | 14 ++++++++++- .../server/maps_telemetry/maps_telemetry.ts | 11 +++------ 9 files changed, 100 insertions(+), 29 deletions(-) diff --git a/src/plugins/data/server/index_patterns/index_patterns_api_client.ts b/src/plugins/data/server/index_patterns/index_patterns_api_client.ts index 941a90f500ab66..0ed84d4eee3b7a 100644 --- a/src/plugins/data/server/index_patterns/index_patterns_api_client.ts +++ b/src/plugins/data/server/index_patterns/index_patterns_api_client.ts @@ -12,6 +12,7 @@ import { IIndexPatternsApiClient, GetFieldsOptionsTimePattern, } from '../../common/index_patterns/types'; +import { IndexPatternMissingIndices } from '../../common/index_patterns/lib'; import { IndexPatternsFetcher } from './fetcher'; export class IndexPatternsApiServer implements IIndexPatternsApiClient { @@ -27,12 +28,23 @@ export class IndexPatternsApiServer implements IIndexPatternsApiClient { allowNoIndex, }: GetFieldsOptions) { const indexPatterns = new IndexPatternsFetcher(this.esClient, allowNoIndex); - return await indexPatterns.getFieldsForWildcard({ - pattern, - metaFields, - type, - rollupIndex, - }); + return await indexPatterns + .getFieldsForWildcard({ + pattern, + metaFields, + type, + rollupIndex, + }) + .catch((err) => { + if ( + err.output.payload.statusCode === 404 && + err.output.payload.code === 'no_matching_indices' + ) { + throw new IndexPatternMissingIndices(pattern); + } else { + throw err; + } + }); } async getFieldsForTimePattern(options: GetFieldsOptionsTimePattern) { const indexPatterns = new IndexPatternsFetcher(this.esClient); diff --git a/src/plugins/data/server/index_patterns/index_patterns_service.ts b/src/plugins/data/server/index_patterns/index_patterns_service.ts index c4cc2073ef78f0..c7fd1f7914df9f 100644 --- a/src/plugins/data/server/index_patterns/index_patterns_service.ts +++ b/src/plugins/data/server/index_patterns/index_patterns_service.ts @@ -71,7 +71,7 @@ export const indexPatternsServiceFactory = ({ logger.error(error); }, onNotification: ({ title, text }) => { - logger.warn(`${title} : ${text}`); + logger.warn(`${title}${text ? ` : ${text}` : ''}`); }, }); }; diff --git a/test/api_integration/apis/index_patterns/fields_api/update_fields/main.ts b/test/api_integration/apis/index_patterns/fields_api/update_fields/main.ts index 33a840fd093fc7..c75b6c607f56e3 100644 --- a/test/api_integration/apis/index_patterns/fields_api/update_fields/main.ts +++ b/test/api_integration/apis/index_patterns/fields_api/update_fields/main.ts @@ -430,7 +430,8 @@ export default function ({ getService }: FtrProviderContext) { }); it('can set field "format" on an existing field', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; + const title = indexPattern.title; + await supertest.delete(`/api/index_patterns/index_pattern/${indexPattern.id}`); const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ index_pattern: { title, diff --git a/test/api_integration/apis/index_patterns/scripted_fields_crud/create_scripted_field/main.ts b/test/api_integration/apis/index_patterns/scripted_fields_crud/create_scripted_field/main.ts index 75450b034f2fda..f9ab482f98b764 100644 --- a/test/api_integration/apis/index_patterns/scripted_fields_crud/create_scripted_field/main.ts +++ b/test/api_integration/apis/index_patterns/scripted_fields_crud/create_scripted_field/main.ts @@ -11,8 +11,17 @@ import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); describe('main', () => { + before(async () => { + await esArchiver.load('index_patterns/basic_index'); + }); + + after(async () => { + await esArchiver.unload('index_patterns/basic_index'); + }); + it('can create a new scripted field', async () => { const title = `foo-${Date.now()}-${Math.random()}*`; const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ @@ -40,7 +49,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('newly created scripted field is materialized in the index_pattern object', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; + const title = `basic_index`; const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ index_pattern: { title, @@ -51,7 +60,7 @@ export default function ({ getService }: FtrProviderContext) { .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/scripted_field`) .send({ field: { - name: 'bar', + name: 'bar2', type: 'number', scripted: true, script: "doc['field_name'].value", @@ -64,12 +73,15 @@ export default function ({ getService }: FtrProviderContext) { expect(response2.status).to.be(200); - const field = response2.body.index_pattern.fields.bar; + const field = response2.body.index_pattern.fields.bar2; - expect(field.name).to.be('bar'); + expect(field.name).to.be('bar2'); expect(field.type).to.be('number'); expect(field.scripted).to.be(true); expect(field.script).to.be("doc['field_name'].value"); + await supertest.delete( + '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id + ); }); }); } diff --git a/test/api_integration/apis/index_patterns/scripted_fields_crud/delete_scripted_field/main.ts b/test/api_integration/apis/index_patterns/scripted_fields_crud/delete_scripted_field/main.ts index 030679a4dd48a6..40f57cd914a2f4 100644 --- a/test/api_integration/apis/index_patterns/scripted_fields_crud/delete_scripted_field/main.ts +++ b/test/api_integration/apis/index_patterns/scripted_fields_crud/delete_scripted_field/main.ts @@ -11,16 +11,25 @@ import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); describe('main', () => { + before(async () => { + await esArchiver.load('index_patterns/basic_index'); + }); + + after(async () => { + await esArchiver.unload('index_patterns/basic_index'); + }); + it('can remove a scripted field', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; + const title = `basic_index`; const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ index_pattern: { title, fields: { bar: { - name: 'bar', + name: 'bar2', type: 'number', scripted: true, script: "doc['field_name'].value", @@ -33,10 +42,10 @@ export default function ({ getService }: FtrProviderContext) { '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id ); - expect(typeof response2.body.index_pattern.fields.bar).to.be('object'); + expect(typeof response2.body.index_pattern.fields.bar2).to.be('object'); const response3 = await supertest.delete( - `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/scripted_field/bar` + `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/scripted_field/bar2` ); expect(response3.status).to.be(200); @@ -45,7 +54,10 @@ export default function ({ getService }: FtrProviderContext) { '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id ); - expect(typeof response4.body.index_pattern.fields.bar).to.be('undefined'); + expect(typeof response4.body.index_pattern.fields.bar2).to.be('undefined'); + await supertest.delete( + '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id + ); }); }); } diff --git a/test/api_integration/apis/index_patterns/scripted_fields_crud/get_scripted_field/main.ts b/test/api_integration/apis/index_patterns/scripted_fields_crud/get_scripted_field/main.ts index c23f41f8b31ddd..7fff720e5195f3 100644 --- a/test/api_integration/apis/index_patterns/scripted_fields_crud/get_scripted_field/main.ts +++ b/test/api_integration/apis/index_patterns/scripted_fields_crud/get_scripted_field/main.ts @@ -11,10 +11,19 @@ import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); describe('main', () => { + before(async () => { + await esArchiver.load('index_patterns/basic_index'); + }); + + after(async () => { + await esArchiver.unload('index_patterns/basic_index'); + }); + it('can fetch a scripted field', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; + const title = `basic_index`; const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ index_pattern: { title, @@ -47,6 +56,9 @@ export default function ({ getService }: FtrProviderContext) { expect(response2.body.field.type).to.be('number'); expect(response2.body.field.scripted).to.be(true); expect(response2.body.field.script).to.be("doc['field_name'].value"); + await supertest.delete( + '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id + ); }); }); } diff --git a/test/api_integration/apis/index_patterns/scripted_fields_crud/put_scripted_field/main.ts b/test/api_integration/apis/index_patterns/scripted_fields_crud/put_scripted_field/main.ts index 3029a351fdae1d..dec20961b0de09 100644 --- a/test/api_integration/apis/index_patterns/scripted_fields_crud/put_scripted_field/main.ts +++ b/test/api_integration/apis/index_patterns/scripted_fields_crud/put_scripted_field/main.ts @@ -11,10 +11,19 @@ import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); describe('main', () => { + before(async () => { + await esArchiver.load('index_patterns/basic_index'); + }); + + after(async () => { + await esArchiver.unload('index_patterns/basic_index'); + }); + it('can overwrite an existing field', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; + const title = `basic_index`; const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ index_pattern: { title, @@ -63,10 +72,13 @@ export default function ({ getService }: FtrProviderContext) { expect(response3.status).to.be(200); expect(response3.body.field.type).to.be('string'); + await supertest.delete( + '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id + ); }); it('can add a new scripted field', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; + const title = `basic_index`; const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ index_pattern: { title, @@ -100,6 +112,9 @@ export default function ({ getService }: FtrProviderContext) { expect(response2.status).to.be(200); expect(response2.body.field.script).to.be("doc['bar'].value"); + await supertest.delete( + '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id + ); }); }); } diff --git a/test/api_integration/apis/index_patterns/scripted_fields_crud/update_scripted_field/main.ts b/test/api_integration/apis/index_patterns/scripted_fields_crud/update_scripted_field/main.ts index 943601d1b2a762..ac6b11522124ba 100644 --- a/test/api_integration/apis/index_patterns/scripted_fields_crud/update_scripted_field/main.ts +++ b/test/api_integration/apis/index_patterns/scripted_fields_crud/update_scripted_field/main.ts @@ -11,10 +11,19 @@ import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); describe('main', () => { + before(async () => { + await esArchiver.load('index_patterns/basic_index'); + }); + + after(async () => { + await esArchiver.unload('index_patterns/basic_index'); + }); + it('can update an existing field', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; + const title = `basic_index`; const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ index_pattern: { title, @@ -56,6 +65,9 @@ export default function ({ getService }: FtrProviderContext) { expect(response3.status).to.be(200); expect(response3.body.field.type).to.be('string'); expect(response3.body.field.script).to.be("doc['bar'].value"); + await supertest.delete( + '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id + ); }); }); } diff --git a/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts b/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts index bf180c514c56fa..569f7e17896f2b 100644 --- a/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts +++ b/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts @@ -125,8 +125,7 @@ async function isFieldGeoShape( if (!indexPattern) { return false; } - const fieldsForIndexPattern = await indexPatternsService.getFieldsForIndexPattern(indexPattern); - return fieldsForIndexPattern.some( + return indexPattern.fields.some( (fieldDescriptor: IFieldType) => fieldDescriptor.name && fieldDescriptor.name === geoField! ); } @@ -192,13 +191,9 @@ async function filterIndexPatternsByField(fields: string[]) { await Promise.all( indexPatternIds.map(async (indexPatternId: string) => { const indexPattern = await indexPatternsService.get(indexPatternId); - const fieldsForIndexPattern = await indexPatternsService.getFieldsForIndexPattern( - indexPattern - ); const containsField = fields.some((field: string) => - fieldsForIndexPattern.some( - (fieldDescriptor: IFieldType) => - fieldDescriptor.esTypes && fieldDescriptor.esTypes.includes(field) + indexPattern.fields.some( + (fieldDescriptor) => fieldDescriptor.esTypes && fieldDescriptor.esTypes.includes(field) ) ); if (containsField) { From 7e20bf85e04dfce3a7718a88c378b76f11b41cb5 Mon Sep 17 00:00:00 2001 From: Garrett Spong Date: Tue, 13 Apr 2021 13:40:13 -0600 Subject: [PATCH 48/90] [Security Solution][Detections] Updates MITRE Tactics, Techniques, and Subtechniques for 7.13 (#97011) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR updates the MITRE Tactics, Techniques, and Subtechniques used within Security Solution Detection Rules. See https://github.com/elastic/kibana/issues/89876 for details on automating this task. 🙂 --- .../mitre/mitre_tactics_techniques.ts | 165 ++++++++++++++---- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 3 files changed, 129 insertions(+), 38 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detections/mitre/mitre_tactics_techniques.ts b/x-pack/plugins/security_solution/public/detections/mitre/mitre_tactics_techniques.ts index b0c02bdbfefc66..a5da747787ba6e 100644 --- a/x-pack/plugins/security_solution/public/detections/mitre/mitre_tactics_techniques.ts +++ b/x-pack/plugins/security_solution/public/detections/mitre/mitre_tactics_techniques.ts @@ -718,12 +718,6 @@ export const technique = [ reference: 'https://attack.mitre.org/techniques/T1061', tactics: ['execution'], }, - { - name: 'Group Policy Modification', - id: 'T1484', - reference: 'https://attack.mitre.org/techniques/T1484', - tactics: ['defense-evasion', 'privilege-escalation'], - }, { name: 'Hardware Additions', id: 'T1200', @@ -1354,6 +1348,18 @@ export const technique = [ reference: 'https://attack.mitre.org/techniques/T1220', tactics: ['defense-evasion'], }, + { + name: 'Domain Policy Modification', + id: 'T1484', + reference: 'https://attack.mitre.org/techniques/T1484', + tactics: ['defense-evasion', 'privilege-escalation'], + }, + { + name: 'Forge Web Credentials', + id: 'T1606', + reference: 'https://attack.mitre.org/techniques/T1606', + tactics: ['credential-access'], + }, ]; export const techniquesOptions: MitreTechniquesOptions[] = [ @@ -2259,17 +2265,6 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ tactics: 'execution', value: 'graphicalUserInterface', }, - { - label: i18n.translate( - 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.groupPolicyModificationDescription', - { defaultMessage: 'Group Policy Modification (T1484)' } - ), - id: 'T1484', - name: 'Group Policy Modification', - reference: 'https://attack.mitre.org/techniques/T1484', - tactics: 'defense-evasion,privilege-escalation', - value: 'groupPolicyModification', - }, { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.hardwareAdditionsDescription', @@ -3425,6 +3420,28 @@ export const techniquesOptions: MitreTechniquesOptions[] = [ tactics: 'defense-evasion', value: 'xslScriptProcessing', }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.domainPolicyModificationDescription', + { defaultMessage: 'Domain Policy Modification (T1484)' } + ), + id: 'T1484', + name: 'Domain Policy Modification', + reference: 'https://attack.mitre.org/techniques/T1484', + tactics: 'defense-evasion,privilege-escalation', + value: 'domainPolicyModification', + }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackTechniques.forgeWebCredentialsDescription', + { defaultMessage: 'Forge Web Credentials (T1606)' } + ), + id: 'T1606', + name: 'Forge Web Credentials', + reference: 'https://attack.mitre.org/techniques/T1606', + tactics: 'credential-access', + value: 'forgeWebCredentials', + }, ]; export const subtechniques = [ @@ -3477,13 +3494,6 @@ export const subtechniques = [ tactics: ['persistence'], techniqueId: 'T1137', }, - { - name: 'Additional Cloud Credentials', - id: 'T1098.001', - reference: 'https://attack.mitre.org/techniques/T1098/001', - tactics: ['persistence'], - techniqueId: 'T1098', - }, { name: 'AppCert DLLs', id: 'T1546.009', @@ -5864,6 +5874,41 @@ export const subtechniques = [ tactics: ['persistence', 'privilege-escalation'], techniqueId: 'T1547', }, + { + name: 'Additional Cloud Credentials', + id: 'T1098.001', + reference: 'https://attack.mitre.org/techniques/T1098/001', + tactics: ['persistence'], + techniqueId: 'T1098', + }, + { + name: 'Group Policy Modification', + id: 'T1484.001', + reference: 'https://attack.mitre.org/techniques/T1484/001', + tactics: ['defense-evasion', 'privilege-escalation'], + techniqueId: 'T1484', + }, + { + name: 'Domain Trust Modification', + id: 'T1484.002', + reference: 'https://attack.mitre.org/techniques/T1484/002', + tactics: ['defense-evasion', 'privilege-escalation'], + techniqueId: 'T1484', + }, + { + name: 'Web Cookies', + id: 'T1606.001', + reference: 'https://attack.mitre.org/techniques/T1606/001', + tactics: ['credential-access'], + techniqueId: 'T1606', + }, + { + name: 'SAML Tokens', + id: 'T1606.002', + reference: 'https://attack.mitre.org/techniques/T1606/002', + tactics: ['credential-access'], + techniqueId: 'T1606', + }, ]; export const subtechniquesOptions: MitreSubtechniquesOptions[] = [ @@ -5951,18 +5996,6 @@ export const subtechniquesOptions: MitreSubtechniquesOptions[] = [ techniqueId: 'T1137', value: 'addIns', }, - { - label: i18n.translate( - 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.additionalCloudCredentialsT1098Description', - { defaultMessage: 'Additional Cloud Credentials (T1098.001)' } - ), - id: 'T1098.001', - name: 'Additional Cloud Credentials', - reference: 'https://attack.mitre.org/techniques/T1098/001', - tactics: 'persistence', - techniqueId: 'T1098', - value: 'additionalCloudCredentials', - }, { label: i18n.translate( 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.appCertDlLsT1546Description', @@ -10043,6 +10076,66 @@ export const subtechniquesOptions: MitreSubtechniquesOptions[] = [ techniqueId: 'T1547', value: 'winlogonHelperDll', }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.additionalCloudCredentialsT1098Description', + { defaultMessage: 'Additional Cloud Credentials (T1098.001)' } + ), + id: 'T1098.001', + name: 'Additional Cloud Credentials', + reference: 'https://attack.mitre.org/techniques/T1098/001', + tactics: 'persistence', + techniqueId: 'T1098', + value: 'additionalCloudCredentials', + }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.groupPolicyModificationT1484Description', + { defaultMessage: 'Group Policy Modification (T1484.001)' } + ), + id: 'T1484.001', + name: 'Group Policy Modification', + reference: 'https://attack.mitre.org/techniques/T1484/001', + tactics: 'defense-evasion,privilege-escalation', + techniqueId: 'T1484', + value: 'groupPolicyModification', + }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.domainTrustModificationT1484Description', + { defaultMessage: 'Domain Trust Modification (T1484.002)' } + ), + id: 'T1484.002', + name: 'Domain Trust Modification', + reference: 'https://attack.mitre.org/techniques/T1484/002', + tactics: 'defense-evasion,privilege-escalation', + techniqueId: 'T1484', + value: 'domainTrustModification', + }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.webCookiesT1606Description', + { defaultMessage: 'Web Cookies (T1606.001)' } + ), + id: 'T1606.001', + name: 'Web Cookies', + reference: 'https://attack.mitre.org/techniques/T1606/001', + tactics: 'credential-access', + techniqueId: 'T1606', + value: 'webCookies', + }, + { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.mitreAttackSubtechniques.samlTokensT1606Description', + { defaultMessage: 'SAML Tokens (T1606.002)' } + ), + id: 'T1606.002', + name: 'SAML Tokens', + reference: 'https://attack.mitre.org/techniques/T1606/002', + tactics: 'credential-access', + techniqueId: 'T1606', + value: 'samlTokens', + }, ]; /** diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 63580981cb3203..014d3d943d9b8f 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -19058,7 +19058,6 @@ "xpack.securitySolution.detectionEngine.mitreAttackTechniques.gatherVictimNetworkInformationDescription": "被害者ネットワーク情報の収集 (T1590) ", "xpack.securitySolution.detectionEngine.mitreAttackTechniques.gatherVictimOrgInformationDescription": "被害者組織情報の収集 (T1591) ", "xpack.securitySolution.detectionEngine.mitreAttackTechniques.graphicalUserInterfaceDescription": "グラフィカルユーザーインターフェイス (T1061) ", - "xpack.securitySolution.detectionEngine.mitreAttackTechniques.groupPolicyModificationDescription": "グループポリシー修正 (T1484) ", "xpack.securitySolution.detectionEngine.mitreAttackTechniques.hardwareAdditionsDescription": "ハードウェア追加 (T1200) ", "xpack.securitySolution.detectionEngine.mitreAttackTechniques.hideArtifactsDescription": "アーチファクトの非表示 (T1564) ", "xpack.securitySolution.detectionEngine.mitreAttackTechniques.hijackExecutionFlowDescription": "ハイジャック実行フロー (T1574) ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 77ef19e61030aa..77324bdddf4792 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -19328,7 +19328,6 @@ "xpack.securitySolution.detectionEngine.mitreAttackTechniques.gatherVictimNetworkInformationDescription": "Gather Victim Network Information (T1590)", "xpack.securitySolution.detectionEngine.mitreAttackTechniques.gatherVictimOrgInformationDescription": "Gather Victim Org Information (T1591)", "xpack.securitySolution.detectionEngine.mitreAttackTechniques.graphicalUserInterfaceDescription": "Graphical User Interface (T1061)", - "xpack.securitySolution.detectionEngine.mitreAttackTechniques.groupPolicyModificationDescription": "Group Policy Modification (T1484)", "xpack.securitySolution.detectionEngine.mitreAttackTechniques.hardwareAdditionsDescription": "Hardware Additions (T1200)", "xpack.securitySolution.detectionEngine.mitreAttackTechniques.hideArtifactsDescription": "Hide Artifacts (T1564)", "xpack.securitySolution.detectionEngine.mitreAttackTechniques.hijackExecutionFlowDescription": "Hijack Execution Flow (T1574)", From 58b1d10f0b945839764587868acf4afcc2b7dfc5 Mon Sep 17 00:00:00 2001 From: John Schulz Date: Tue, 13 Apr 2021 15:42:36 -0400 Subject: [PATCH 49/90] Copy esArchiver commands from ./reassign.ts to fix tests (#97012) ## Summary Seeing failures like this locally for `x-pack/test/fleet_api_integration/apis/agents/unenroll.ts` tests
screenshot of error Screen Shot 2021-04-13 at 10 06 51 AM
Copied the `esArchiver` patterns from `x-pack/test/fleet_api_integration/apis/agents/reassign.ts` in https://github.com/elastic/kibana/pull/96837 and the error is gone ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../test/fleet_api_integration/apis/agents/unenroll.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/x-pack/test/fleet_api_integration/apis/agents/unenroll.ts b/x-pack/test/fleet_api_integration/apis/agents/unenroll.ts index ab765eae18ca5e..d7e16b7e7224bf 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/unenroll.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/unenroll.ts @@ -23,10 +23,12 @@ export default function (providerContext: FtrProviderContext) { let accessAPIKeyId: string; let outputAPIKeyId: string; before(async () => { - await esArchiver.load('fleet/agents'); + await esArchiver.load('fleet/empty_fleet_server'); }); setupFleetAndAgents(providerContext); beforeEach(async () => { + await esArchiver.unload('fleet/empty_fleet_server'); + await esArchiver.load('fleet/agents'); const { body: accessAPIKeyBody } = await esClient.security.createApiKey({ body: { name: `test access api key: ${uuid.v4()}`, @@ -63,8 +65,12 @@ export default function (providerContext: FtrProviderContext) { }, }); }); - after(async () => { + afterEach(async () => { await esArchiver.unload('fleet/agents'); + await esArchiver.load('fleet/empty_fleet_server'); + }); + after(async () => { + await esArchiver.unload('fleet/empty_fleet_server'); }); it('/agents/{agent_id}/unenroll should fail for managed policy', async () => { From d774a41aefbbe57fd35bb55dc9c4a88925690388 Mon Sep 17 00:00:00 2001 From: Constance Date: Tue, 13 Apr 2021 12:56:22 -0700 Subject: [PATCH 50/90] [App Search] Add small engine breadcrumb utility helper (#96917) * Add new getEngineBreadcrumbs utility helper * Update all routes passing engineBreadcrumb as a prop to use new helper --- .../app_search/__mocks__/engine_logic.mock.ts | 7 +++++++ .../analytics/analytics_router.test.tsx | 2 +- .../components/analytics/analytics_router.tsx | 10 +++------ .../components/api_logs/api_logs.test.tsx | 11 +++++----- .../components/api_logs/api_logs.tsx | 9 +++----- .../curations/curations_router.test.tsx | 4 +++- .../components/curations/curations_router.tsx | 9 +++----- .../documents/document_detail.test.tsx | 15 ++++++------- .../components/documents/document_detail.tsx | 8 +++---- .../components/documents/documents.test.tsx | 13 ++++++------ .../components/documents/documents.tsx | 10 +++------ .../components/engine/engine_router.tsx | 21 ++++++++----------- .../app_search/components/engine/index.ts | 2 +- .../components/engine/utils.test.ts | 18 ++++++++++++++-- .../app_search/components/engine/utils.ts | 11 ++++++++++ .../relevance_tuning.test.tsx | 6 ++++-- .../relevance_tuning/relevance_tuning.tsx | 8 ++----- .../relevance_tuning_layout.test.tsx | 3 ++- .../relevance_tuning_layout.tsx | 9 +++----- .../result_settings/result_settings.test.tsx | 8 +++---- .../result_settings/result_settings.tsx | 10 +++------ 21 files changed, 102 insertions(+), 92 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_logic.mock.ts index 485ac19f2eb820..d16391089120a2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_logic.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_logic.mock.ts @@ -6,6 +6,7 @@ */ import { EngineDetails } from '../components/engine/types'; +import { ENGINES_TITLE } from '../components/engines'; import { generateEncodedPath } from '../utils/encode_path_params'; export const mockEngineValues = { @@ -20,6 +21,11 @@ export const mockEngineActions = { export const mockGenerateEnginePath = jest.fn((path, pathParams = {}) => generateEncodedPath(path, { engineName: mockEngineValues.engineName, ...pathParams }) ); +export const mockGetEngineBreadcrumbs = jest.fn((breadcrumbs = []) => [ + ENGINES_TITLE, + mockEngineValues.engineName, + ...breadcrumbs, +]); jest.mock('../components/engine', () => ({ EngineLogic: { @@ -27,4 +33,5 @@ jest.mock('../components/engine', () => ({ actions: mockEngineActions, }, generateEnginePath: mockGenerateEnginePath, + getEngineBreadcrumbs: mockGetEngineBreadcrumbs, })); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.test.tsx index 3940151d3b7cdf..68f08d8d847245 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.test.tsx @@ -18,7 +18,7 @@ import { AnalyticsRouter } from './'; describe('AnalyticsRouter', () => { // Detailed route testing is better done via E2E tests it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(Switch)).toHaveLength(1); expect(wrapper.find(Route)).toHaveLength(9); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.tsx index 7bd4664cdbfa3a..397f1f1e1e1c38 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_router.tsx @@ -10,7 +10,6 @@ import { Route, Switch, Redirect } from 'react-router-dom'; import { APP_SEARCH_PLUGIN } from '../../../../../common/constants'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { BreadcrumbTrail } from '../../../shared/kibana_chrome/generate_breadcrumbs'; import { NotFound } from '../../../shared/not_found'; import { ENGINE_ANALYTICS_PATH, @@ -22,7 +21,7 @@ import { ENGINE_ANALYTICS_QUERY_DETAILS_PATH, ENGINE_ANALYTICS_QUERY_DETAIL_PATH, } from '../../routes'; -import { generateEnginePath } from '../engine'; +import { generateEnginePath, getEngineBreadcrumbs } from '../engine'; import { ANALYTICS_TITLE, @@ -42,11 +41,8 @@ import { QueryDetail, } from './views'; -interface Props { - engineBreadcrumb: BreadcrumbTrail; -} -export const AnalyticsRouter: React.FC = ({ engineBreadcrumb }) => { - const ANALYTICS_BREADCRUMB = [...engineBreadcrumb, ANALYTICS_TITLE]; +export const AnalyticsRouter: React.FC = () => { + const ANALYTICS_BREADCRUMB = getEngineBreadcrumbs([ANALYTICS_TITLE]); return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.test.tsx index 1945dde84ec450..cb29d92030ad7f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.test.tsx @@ -7,10 +7,11 @@ import { setMockValues, setMockActions, rerender } from '../../../__mocks__'; import '../../../__mocks__/shallow_useeffect.mock'; +import '../../__mocks__/engine_logic.mock'; import React from 'react'; -import { shallow, ShallowWrapper } from 'enzyme'; +import { shallow } from 'enzyme'; import { EuiPageHeader } from '@elastic/eui'; @@ -32,16 +33,14 @@ describe('ApiLogs', () => { pollForApiLogs: jest.fn(), }; - let wrapper: ShallowWrapper; - beforeEach(() => { jest.clearAllMocks(); setMockValues(values); setMockActions(actions); - wrapper = shallow(); }); it('renders', () => { + const wrapper = shallow(); expect(wrapper.find(EuiPageHeader).prop('pageTitle')).toEqual('API Logs'); expect(wrapper.find(ApiLogsTable)).toHaveLength(1); expect(wrapper.find(NewApiEventsPrompt)).toHaveLength(1); @@ -52,13 +51,14 @@ describe('ApiLogs', () => { it('renders a loading screen', () => { setMockValues({ ...values, dataLoading: true, apiLogs: [] }); - rerender(wrapper); + const wrapper = shallow(); expect(wrapper.find(Loading)).toHaveLength(1); }); describe('effects', () => { it('calls a manual fetchApiLogs on page load and pagination', () => { + const wrapper = shallow(); expect(actions.fetchApiLogs).toHaveBeenCalledTimes(1); setMockValues({ ...values, meta: { page: { current: 2 } } }); @@ -68,6 +68,7 @@ describe('ApiLogs', () => { }); it('starts pollForApiLogs on page load', () => { + shallow(); expect(actions.pollForApiLogs).toHaveBeenCalledTimes(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.tsx index 4690911fad7724..b8179163c93f94 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.tsx @@ -21,9 +21,9 @@ import { import { FlashMessages } from '../../../shared/flash_messages'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { BreadcrumbTrail } from '../../../shared/kibana_chrome/generate_breadcrumbs'; import { Loading } from '../../../shared/loading'; +import { getEngineBreadcrumbs } from '../engine'; import { LogRetentionCallout, LogRetentionTooltip, LogRetentionOptions } from '../log_retention'; import { ApiLogFlyout } from './api_log'; @@ -32,10 +32,7 @@ import { API_LOGS_TITLE, RECENT_API_EVENTS } from './constants'; import { ApiLogsLogic } from './'; -interface Props { - engineBreadcrumb: BreadcrumbTrail; -} -export const ApiLogs: React.FC = ({ engineBreadcrumb }) => { +export const ApiLogs: React.FC = () => { const { dataLoading, apiLogs, meta } = useValues(ApiLogsLogic); const { fetchApiLogs, pollForApiLogs } = useActions(ApiLogsLogic); @@ -51,7 +48,7 @@ export const ApiLogs: React.FC = ({ engineBreadcrumb }) => { return ( <> - + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.test.tsx index f0eafb13bb9b05..9598212d3e0c98 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.test.tsx @@ -5,6 +5,8 @@ * 2.0. */ +import '../../__mocks__/engine_logic.mock'; + import React from 'react'; import { Route, Switch } from 'react-router-dom'; @@ -14,7 +16,7 @@ import { CurationsRouter } from './'; describe('CurationsRouter', () => { it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(Switch)).toHaveLength(1); expect(wrapper.find(Route)).toHaveLength(4); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.tsx index e080f7de133906..28ce311b438875 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.tsx @@ -10,23 +10,20 @@ import { Route, Switch } from 'react-router-dom'; import { APP_SEARCH_PLUGIN } from '../../../../../common/constants'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { BreadcrumbTrail } from '../../../shared/kibana_chrome/generate_breadcrumbs'; import { NotFound } from '../../../shared/not_found'; import { ENGINE_CURATIONS_PATH, ENGINE_CURATIONS_NEW_PATH, ENGINE_CURATION_PATH, } from '../../routes'; +import { getEngineBreadcrumbs } from '../engine'; import { CURATIONS_TITLE, CREATE_NEW_CURATION_TITLE } from './constants'; import { Curation } from './curation'; import { Curations, CurationCreation } from './views'; -interface Props { - engineBreadcrumb: BreadcrumbTrail; -} -export const CurationsRouter: React.FC = ({ engineBreadcrumb }) => { - const CURATIONS_BREADCRUMB = [...engineBreadcrumb, CURATIONS_TITLE]; +export const CurationsRouter: React.FC = () => { + const CURATIONS_BREADCRUMB = getEngineBreadcrumbs([CURATIONS_TITLE]); return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx index a33161918c7f5f..c4563b43571345 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx @@ -5,9 +5,10 @@ * 2.0. */ -import '../../../__mocks__/react_router_history.mock'; import { setMockValues, setMockActions } from '../../../__mocks__/kea.mock'; import { unmountHandler } from '../../../__mocks__/shallow_useeffect.mock'; +import '../../../__mocks__/react_router_history.mock'; +import '../../__mocks__/engine_logic.mock'; import React from 'react'; import { useParams } from 'react-router-dom'; @@ -44,17 +45,17 @@ describe('DocumentDetail', () => { }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(EuiPageContent).length).toBe(1); }); it('initializes data on mount', () => { - shallow(); + shallow(); expect(actions.getDocumentDetails).toHaveBeenCalledWith('1'); }); it('calls setFields on unmount', () => { - shallow(); + shallow(); unmountHandler(); expect(actions.setFields).toHaveBeenCalledWith([]); }); @@ -65,7 +66,7 @@ describe('DocumentDetail', () => { dataLoading: true, }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(Loading).length).toBe(1); }); @@ -80,7 +81,7 @@ describe('DocumentDetail', () => { }; beforeEach(() => { - const wrapper = shallow(); + const wrapper = shallow(); columns = wrapper.find(EuiBasicTable).props().columns; }); @@ -101,7 +102,7 @@ describe('DocumentDetail', () => { }); it('will delete the document when the delete button is pressed', () => { - const wrapper = shallow(); + const wrapper = shallow(); const header = wrapper.find(EuiPageHeader).dive().children().dive(); const button = header.find('[data-test-subj="DeleteDocumentButton"]'); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx index fefe983df33420..314c3529cf4db7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx @@ -25,6 +25,7 @@ import { FlashMessages } from '../../../shared/flash_messages'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { Loading } from '../../../shared/loading'; import { useDecodedParams } from '../../utils/encode_path_params'; +import { getEngineBreadcrumbs } from '../engine'; import { ResultFieldValue } from '../result'; import { DOCUMENTS_TITLE } from './constants'; @@ -36,11 +37,8 @@ const DOCUMENT_DETAIL_TITLE = (documentId: string) => defaultMessage: 'Document: {documentId}', values: { documentId }, }); -interface Props { - engineBreadcrumb: string[]; -} -export const DocumentDetail: React.FC = ({ engineBreadcrumb }) => { +export const DocumentDetail: React.FC = () => { const { dataLoading, fields } = useValues(DocumentDetailLogic); const { deleteDocument, getDocumentDetails, setFields } = useActions(DocumentDetailLogic); @@ -77,7 +75,7 @@ export const DocumentDetail: React.FC = ({ engineBreadcrumb }) => { return ( <> - + { }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(SearchExperience).exists()).toBe(true); }); @@ -44,7 +45,7 @@ describe('Documents', () => { myRole: { canManageEngineDocuments: true }, }); - const wrapper = shallow(); + const wrapper = shallow(); expect(getHeader(wrapper).find(DocumentCreationButton).exists()).toBe(true); }); @@ -54,7 +55,7 @@ describe('Documents', () => { myRole: { canManageEngineDocuments: false }, }); - const wrapper = shallow(); + const wrapper = shallow(); expect(getHeader(wrapper).find(DocumentCreationButton).exists()).toBe(false); }); @@ -65,7 +66,7 @@ describe('Documents', () => { isMetaEngine: true, }); - const wrapper = shallow(); + const wrapper = shallow(); expect(getHeader(wrapper).find(DocumentCreationButton).exists()).toBe(false); }); }); @@ -77,7 +78,7 @@ describe('Documents', () => { isMetaEngine: true, }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find('[data-test-subj="MetaEnginesCallout"]').exists()).toBe(true); }); @@ -87,7 +88,7 @@ describe('Documents', () => { isMetaEngine: false, }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find('[data-test-subj="MetaEnginesCallout"]').exists()).toBe(false); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx index 84fcab53e96041..58aa6acc59783a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx @@ -16,23 +16,19 @@ import { FlashMessages } from '../../../shared/flash_messages'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { AppLogic } from '../../app_logic'; -import { EngineLogic } from '../engine'; +import { EngineLogic, getEngineBreadcrumbs } from '../engine'; import { DOCUMENTS_TITLE } from './constants'; import { DocumentCreationButton } from './document_creation_button'; import { SearchExperience } from './search_experience'; -interface Props { - engineBreadcrumb: string[]; -} - -export const Documents: React.FC = ({ engineBreadcrumb }) => { +export const Documents: React.FC = () => { const { isMetaEngine } = useValues(EngineLogic); const { myRole } = useValues(AppLogic); return ( <> - + { const { @@ -85,43 +84,41 @@ export const EngineRouter: React.FC = () => { const isLoadingNewEngine = engineName !== engineNameFromUrl; if (isLoadingNewEngine || dataLoading) return ; - const engineBreadcrumb = [ENGINES_TITLE, engineName]; - return ( {canViewEngineAnalytics && ( - + )} - + - + {canManageEngineCurations && ( - + )} {canManageEngineRelevanceTuning && ( - + )} {canManageEngineResultSettings && ( - + )} {canViewEngineApiLogs && ( - + )} - + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/index.ts index 80c36822ccde0f..2a5b3351f41f70 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/index.ts @@ -8,4 +8,4 @@ export { EngineRouter } from './engine_router'; export { EngineNav } from './engine_nav'; export { EngineLogic } from './engine_logic'; -export { generateEnginePath } from './utils'; +export { generateEnginePath, getEngineBreadcrumbs } from './utils'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/utils.test.ts index 867ed14fcc0527..be6b9a53bd0d5a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/utils.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/utils.test.ts @@ -7,10 +7,12 @@ import { mockEngineValues } from '../../__mocks__'; -import { generateEnginePath } from './utils'; +import { generateEnginePath, getEngineBreadcrumbs } from './utils'; describe('generateEnginePath', () => { - mockEngineValues.engineName = 'hello-world'; + beforeEach(() => { + mockEngineValues.engineName = 'hello-world'; + }); it('generates paths with engineName filled from state', () => { expect(generateEnginePath('/engines/:engineName/example')).toEqual( @@ -27,3 +29,15 @@ describe('generateEnginePath', () => { ).toEqual('/engines/override/foo/baz'); }); }); + +describe('getEngineBreadcrumbs', () => { + beforeEach(() => { + mockEngineValues.engineName = 'foo'; + }); + + it('generates breadcrumbs with engineName filled from state', () => { + expect(getEngineBreadcrumbs(['bar', 'baz'])).toEqual(['Engines', 'foo', 'bar', 'baz']); + expect(getEngineBreadcrumbs(['bar'])).toEqual(['Engines', 'foo', 'bar']); + expect(getEngineBreadcrumbs()).toEqual(['Engines', 'foo']); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/utils.ts index 7b8521105875c6..820d89e4739222 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/utils.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/utils.ts @@ -5,8 +5,11 @@ * 2.0. */ +import { BreadcrumbTrail } from '../../../shared/kibana_chrome/generate_breadcrumbs'; import { generateEncodedPath } from '../../utils/encode_path_params'; +import { ENGINES_TITLE } from '../engines'; + import { EngineLogic } from './'; /** @@ -16,3 +19,11 @@ export const generateEnginePath = (path: string, pathParams: object = {}) => { const { engineName } = EngineLogic.values; return generateEncodedPath(path, { engineName, ...pathParams }); }; + +/** + * Generate a breadcrumb trail with engineName automatically filled from EngineLogic state + */ +export const getEngineBreadcrumbs = (breadcrumbs: BreadcrumbTrail = []) => { + const { engineName } = EngineLogic.values; + return [ENGINES_TITLE, engineName, ...breadcrumbs]; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.test.tsx index e2adce7dd76876..c76c50094aeddc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.test.tsx @@ -4,8 +4,10 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import '../../../__mocks__/shallow_useeffect.mock'; + import { setMockActions, setMockValues } from '../../../__mocks__/kea.mock'; +import '../../../__mocks__/shallow_useeffect.mock'; +import '../../__mocks__/engine_logic.mock'; import React from 'react'; @@ -37,7 +39,7 @@ describe('RelevanceTuning', () => { resetSearchSettings: jest.fn(), }; - const subject = () => shallow(); + const subject = () => shallow(); beforeEach(() => { jest.clearAllMocks(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.tsx index 70adc91dd2b301..ab9bbaa9a1773e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.tsx @@ -23,10 +23,6 @@ import { RelevanceTuningPreview } from './relevance_tuning_preview'; import { RelevanceTuningLogic } from '.'; -interface Props { - engineBreadcrumb: string[]; -} - const EmptyCallout: React.FC = () => { return ( { ); }; -export const RelevanceTuning: React.FC = ({ engineBreadcrumb }) => { +export const RelevanceTuning: React.FC = () => { const { dataLoading, engineHasSchemaFields, unsavedChanges } = useValues(RelevanceTuningLogic); const { initializeRelevanceTuning } = useActions(RelevanceTuningLogic); @@ -95,7 +91,7 @@ export const RelevanceTuning: React.FC = ({ engineBreadcrumb }) => { }; return ( - + {body()} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.test.tsx index 9ed6e17c2bcd94..6f4333d94919b8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.test.tsx @@ -6,6 +6,7 @@ */ import { setMockActions, setMockValues } from '../../../__mocks__/kea.mock'; +import '../../__mocks__/engine_logic.mock'; import React from 'react'; @@ -32,7 +33,7 @@ describe('RelevanceTuningLayout', () => { setMockActions(actions); }); - const subject = () => shallow(); + const subject = () => shallow(); const findButtons = (wrapper: ShallowWrapper) => wrapper.find(EuiPageHeader).prop('rightSideItems') as React.ReactElement[]; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.tsx index f29cc12f20a98c..69043d80bd8d00 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.tsx @@ -17,16 +17,13 @@ import { SAVE_BUTTON_LABEL } from '../../../shared/constants'; import { FlashMessages } from '../../../shared/flash_messages'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { RESTORE_DEFAULTS_BUTTON_LABEL } from '../../constants'; +import { getEngineBreadcrumbs } from '../engine'; import { RELEVANCE_TUNING_TITLE } from './constants'; import { RelevanceTuningCallouts } from './relevance_tuning_callouts'; import { RelevanceTuningLogic } from './relevance_tuning_logic'; -interface Props { - engineBreadcrumb: string[]; -} - -export const RelevanceTuningLayout: React.FC = ({ engineBreadcrumb, children }) => { +export const RelevanceTuningLayout: React.FC = ({ children }) => { const { resetSearchSettings, updateSearchSettings } = useActions(RelevanceTuningLogic); const { engineHasSchemaFields } = useValues(RelevanceTuningLogic); @@ -66,7 +63,7 @@ export const RelevanceTuningLayout: React.FC = ({ engineBreadcrumb, child return ( <> - + {pageHeader()} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx index 5365cc0f029f83..a1e1fd920b1398 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import '../../../__mocks__/shallow_useeffect.mock'; - import { setMockValues, setMockActions } from '../../../__mocks__'; +import '../../../__mocks__/shallow_useeffect.mock'; +import '../../__mocks__/engine_logic.mock'; import React from 'react'; @@ -37,7 +37,7 @@ describe('RelevanceTuning', () => { jest.clearAllMocks(); }); - const subject = () => shallow(); + const subject = () => shallow(); const findButtons = (wrapper: ShallowWrapper) => wrapper.find(EuiPageHeader).prop('rightSideItems') as React.ReactElement[]; @@ -48,7 +48,7 @@ describe('RelevanceTuning', () => { }); it('initializes result settings data when mounted', () => { - shallow(); + shallow(); expect(actions.initializeResultSettingsData).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx index a513d0c1b9f34c..70dbee7425ae81 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx @@ -18,10 +18,10 @@ import { FlashMessages } from '../../../shared/flash_messages'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { Loading } from '../../../shared/loading'; import { RESTORE_DEFAULTS_BUTTON_LABEL } from '../../constants'; +import { getEngineBreadcrumbs } from '../engine'; import { RESULT_SETTINGS_TITLE } from './constants'; import { ResultSettingsTable } from './result_settings_table'; - import { SampleResponse } from './sample_response'; import { ResultSettingsLogic } from '.'; @@ -31,11 +31,7 @@ const CLEAR_BUTTON_LABEL = i18n.translate( { defaultMessage: 'Clear all values' } ); -interface Props { - engineBreadcrumb: string[]; -} - -export const ResultSettings: React.FC = ({ engineBreadcrumb }) => { +export const ResultSettings: React.FC = () => { const { dataLoading } = useValues(ResultSettingsLogic); const { initializeResultSettingsData, @@ -52,7 +48,7 @@ export const ResultSettings: React.FC = ({ engineBreadcrumb }) => { return ( <> - + Date: Tue, 13 Apr 2021 15:52:37 -0500 Subject: [PATCH 51/90] Index pattern field editor - Add warning on name or type change (#95528) * add warning on name or type change --- .../field_editor/field_editor.test.tsx | 2 +- .../components/field_editor/field_editor.tsx | 23 +++++++++++++++++++ .../apps/management/_runtime_fields.js | 1 + 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.test.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.test.tsx index 7d79200bc6f877..b3fada3dbd00ff 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.test.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.test.tsx @@ -268,7 +268,7 @@ describe('', () => { expect(form.getErrorsMessages()).toEqual(['Awwww! Painless syntax error']); // We change the type and expect the form error to not be there anymore - await changeFieldType('long'); + await changeFieldType('keyword'); expect(form.getErrorsMessages()).toEqual([]); }); }); diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx index 3785096e206273..fc25879b128ec0 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx @@ -15,6 +15,7 @@ import { EuiSpacer, EuiComboBoxOptionOption, EuiCode, + EuiCallOut, } from '@elastic/eui'; import type { CoreStart } from 'src/core/public'; @@ -138,6 +139,11 @@ const geti18nTexts = (): { }, }); +const changeWarning = i18n.translate('indexPatternFieldEditor.editor.form.changeWarning', { + defaultMessage: + 'Changing name or type can break searches and visualizations that rely on this field.', +}); + const formDeserializer = (field: Field): FieldFormInternal => { let fieldType: Array>; if (!field.type) { @@ -204,6 +210,11 @@ const FieldEditorComponent = ({ clearSyntaxError(); }, [type, clearSyntaxError]); + const [{ name: updatedName, type: updatedType }] = useFormData({ form }); + const nameHasChanged = Boolean(field?.name) && field?.name !== updatedName; + const typeHasChanged = + Boolean(field?.type) && field?.type !== (updatedType && updatedType[0].value); + return (
@@ -231,6 +242,18 @@ const FieldEditorComponent = ({ + {(nameHasChanged || typeHasChanged) && ( + <> + + + + )} {/* Set custom label */} diff --git a/test/functional/apps/management/_runtime_fields.js b/test/functional/apps/management/_runtime_fields.js index e2227d4240d409..44abf07b38ac65 100644 --- a/test/functional/apps/management/_runtime_fields.js +++ b/test/functional/apps/management/_runtime_fields.js @@ -55,6 +55,7 @@ export default function ({ getService, getPageObjects }) { await testSubjects.click('editFieldFormat'); await PageObjects.settings.setFieldType('Long'); await PageObjects.settings.changeFieldScript('emit(6);'); + await testSubjects.find('changeWarning'); await PageObjects.settings.clickSaveField(); await PageObjects.settings.confirmSave(); }); From a66bb5394d2c68ec45e09f576c407fb3ad4379c7 Mon Sep 17 00:00:00 2001 From: Andrew Goldstein Date: Tue, 13 Apr 2021 14:55:04 -0600 Subject: [PATCH 52/90] ## [Security Solution] Fixes `Exit full screen` and `Copy to cliboard` styling issues (#96676) ## [Security Solution] Fixes `Exit full screen` and `Copy to clipboard` styling issues Note: This PR is `release_note:skip` because the styling issues below do not effect any previous release. - Fixes issue https://github.com/elastic/kibana/issues/96209 where the `Exit full screen` button in Timeline's `Pinned` tab is rendered adjacent to, instead of above, the table: ### Before: Exit Full Screen (`Pinned` tab) ![exit-full-screen-before](https://user-images.githubusercontent.com/4459398/114104665-89372980-9888-11eb-9158-ffa9c5a5ce17.png) _Before: The `Exit full screen` button on Timeline's `Pinned` tab_ ### After: Exit Full Screen (`Pinned` tab) ![exit-full-screen-after](https://user-images.githubusercontent.com/4459398/114106055-3743d300-988b-11eb-9c4d-c08679702d05.png) _After: The `Exit full screen` button on Timeline's `Pinned` tab_ - Fixes an issue where the `Copy to clipboard` hover menu action was not aligned with the other hover menu actions: ### Before: Copy to clipboard hover action ![copy-to-clipboard-before](https://user-images.githubusercontent.com/4459398/114106138-5c384600-988b-11eb-942e-ae4e09848b09.png) _Before: The `Copy to clipboard` hover action was not aligned_ ### After: Copy to clipboard hover action ![copy-to-clipboard-after](https://user-images.githubusercontent.com/4459398/114106236-8db11180-988b-11eb-85ae-476ac6d1df4e.png) _After: The `Copy to clipboard` hover action is aligned_ ### Desk Testing Desk tested in: - Chrome `89.0.4389.114` - Firefox `87.0` - Safari `14.0.3` --- .../lib/clipboard/with_copy_to_clipboard.tsx | 17 ++-------------- .../timeline/pinned_tab_content/index.tsx | 20 +++++++++---------- 2 files changed, 11 insertions(+), 26 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/lib/clipboard/with_copy_to_clipboard.tsx b/x-pack/plugins/security_solution/public/common/lib/clipboard/with_copy_to_clipboard.tsx index bec1b296d48541..1baa57166de3fb 100644 --- a/x-pack/plugins/security_solution/public/common/lib/clipboard/with_copy_to_clipboard.tsx +++ b/x-pack/plugins/security_solution/public/common/lib/clipboard/with_copy_to_clipboard.tsx @@ -7,22 +7,12 @@ import { EuiToolTip } from '@elastic/eui'; import React from 'react'; -import styled from 'styled-components'; import { TooltipWithKeyboardShortcut } from '../../components/accessibility/tooltip_with_keyboard_shortcut'; import * as i18n from '../../components/drag_and_drop/translations'; import { Clipboard } from './clipboard'; -const WithCopyToClipboardContainer = styled.div` - align-items: center; - display: flex; - flex-direction: row; - user-select: text; -`; - -WithCopyToClipboardContainer.displayName = 'WithCopyToClipboardContainer'; - /** * Renders `children` with an adjacent icon that when clicked, copies `text` to * the clipboard and displays a confirmation toast @@ -31,7 +21,7 @@ export const WithCopyToClipboard = React.memo<{ keyboardShortcut?: string; text: string; titleSummary?: string; -}>(({ keyboardShortcut = '', text, titleSummary, children }) => ( +}>(({ keyboardShortcut = '', text, titleSummary }) => ( } > - - <>{children} - - + )); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx index dfc14747dacf35..a3fd991da57823 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx @@ -62,11 +62,7 @@ const StyledEuiFlyoutFooter = styled(EuiFlyoutFooter)` } `; -const ExitFullScreenFlexItem = styled(EuiFlexItem)` - &.euiFlexItem { - ${({ theme }) => `margin: ${theme.eui.euiSizeS} 0 0 ${theme.eui.euiSizeS};`} - } - +const ExitFullScreenContainer = styled.div` width: 180px; `; @@ -205,13 +201,15 @@ export const PinnedTabContentComponent: React.FC = ({ return ( <> - {timelineFullScreen && setTimelineFullScreen != null && ( - - - - )} - + {timelineFullScreen && setTimelineFullScreen != null && ( + + + + )} Date: Tue, 13 Apr 2021 15:57:38 -0500 Subject: [PATCH 53/90] [Workplace Search] Hide Kibana chrome on 3rd party connector redirects (#97028) --- .../views/content_sources/components/source_added.test.tsx | 5 ++++- .../views/content_sources/components/source_added.tsx | 7 ++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_added.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_added.test.tsx index ddf89159b26755..9eecc41aa17780 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_added.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_added.test.tsx @@ -7,7 +7,7 @@ import '../../../../__mocks__/shallow_useeffect.mock'; -import { setMockActions } from '../../../../__mocks__'; +import { setMockActions, setMockValues } from '../../../../__mocks__'; import React from 'react'; import { useLocation } from 'react-router-dom'; @@ -20,9 +20,11 @@ import { SourceAdded } from './source_added'; describe('SourceAdded', () => { const saveSourceParams = jest.fn(); + const setChromeIsVisible = jest.fn(); beforeEach(() => { setMockActions({ saveSourceParams }); + setMockValues({ setChromeIsVisible }); }); it('renders', () => { @@ -32,5 +34,6 @@ describe('SourceAdded', () => { expect(wrapper.find(Loading)).toHaveLength(1); expect(saveSourceParams).toHaveBeenCalled(); + expect(setChromeIsVisible).toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_added.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_added.tsx index 7c4e81d8e0755c..5b93b7a426936e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_added.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_added.tsx @@ -9,10 +9,11 @@ import React, { useEffect } from 'react'; import { useLocation } from 'react-router-dom'; import { Location } from 'history'; -import { useActions } from 'kea'; +import { useActions, useValues } from 'kea'; import { EuiPage, EuiPageBody } from '@elastic/eui'; +import { KibanaLogic } from '../../../../shared/kibana'; import { Loading } from '../../../../shared/loading'; import { AddSourceLogic } from './add_source/add_source_logic'; @@ -24,8 +25,12 @@ import { AddSourceLogic } from './add_source/add_source_logic'; */ export const SourceAdded: React.FC = () => { const { search } = useLocation() as Location; + const { setChromeIsVisible } = useValues(KibanaLogic); const { saveSourceParams } = useActions(AddSourceLogic); + // We don't want the personal dashboard to flash the Kibana chrome, so we hide it. + setChromeIsVisible(false); + useEffect(() => { saveSourceParams(search); }, []); From 355c949463cec5b2169081a809722d55db0e5bf3 Mon Sep 17 00:00:00 2001 From: igoristic Date: Tue, 13 Apr 2021 17:01:39 -0400 Subject: [PATCH 54/90] [Monitoring] Using primary average shard size (#96177) * Using shard size avg instead of primary total * Added ui text * Changed to primary average instead of total * Addressed cr feedback * Added zero check * Fixed threshold checking * Changed description Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- docs/user/monitoring/kibana-alerts.asciidoc | 4 +-- x-pack/plugins/monitoring/common/constants.ts | 4 +-- x-pack/plugins/monitoring/common/types/es.ts | 3 ++ .../server/alerts/large_shard_size_alert.ts | 4 +-- .../lib/alerts/fetch_index_shard_size.ts | 30 ++++++++++++------- 5 files changed, 29 insertions(+), 16 deletions(-) diff --git a/docs/user/monitoring/kibana-alerts.asciidoc b/docs/user/monitoring/kibana-alerts.asciidoc index bbc9c41c6ca5a6..2944921edd2eea 100644 --- a/docs/user/monitoring/kibana-alerts.asciidoc +++ b/docs/user/monitoring/kibana-alerts.asciidoc @@ -81,8 +81,8 @@ by running checks on a schedule time of 1 minute with a re-notify interval of 6 [[kibana-alerts-large-shard-size]] == Large shard size -This alert is triggered if a large (primary) shard size is found on any of the -specified index patterns. The trigger condition is met if an index's shard size is +This alert is triggered if a large average shard size (across associated primaries) is found on any of the +specified index patterns. The trigger condition is met if an index's average shard size is 55gb or higher in the last 5 minutes. The alert is grouped across all indices that match the default pattern of `*` by running checks on a schedule time of 1 minute with a re-notify interval of 12 hours. diff --git a/x-pack/plugins/monitoring/common/constants.ts b/x-pack/plugins/monitoring/common/constants.ts index bf6e32af0dc391..cd3e28debb7d50 100644 --- a/x-pack/plugins/monitoring/common/constants.ts +++ b/x-pack/plugins/monitoring/common/constants.ts @@ -460,7 +460,7 @@ export const ALERT_DETAILS = { paramDetails: { threshold: { label: i18n.translate('xpack.monitoring.alerts.shardSize.paramDetails.threshold.label', { - defaultMessage: `Notify when a shard exceeds this size`, + defaultMessage: `Notify when average shard size exceeds this value`, }), type: AlertParamType.Number, append: 'GB', @@ -477,7 +477,7 @@ export const ALERT_DETAILS = { defaultMessage: 'Shard size', }), description: i18n.translate('xpack.monitoring.alerts.shardSize.description', { - defaultMessage: 'Alert if an index (primary) shard is oversize.', + defaultMessage: 'Alert if the average shard size is larger than the configured threshold.', }), }, }; diff --git a/x-pack/plugins/monitoring/common/types/es.ts b/x-pack/plugins/monitoring/common/types/es.ts index 9dce32211f4b1a..38a7e7859272ca 100644 --- a/x-pack/plugins/monitoring/common/types/es.ts +++ b/x-pack/plugins/monitoring/common/types/es.ts @@ -100,6 +100,9 @@ export interface ElasticsearchNodeStats { export interface ElasticsearchIndexStats { index?: string; + shards: { + primaries: number; + }; primaries?: { docs?: { count?: number; diff --git a/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts b/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts index 2c9e5a04e37e42..db318d7962beb9 100644 --- a/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts @@ -49,7 +49,7 @@ export class LargeShardSizeAlert extends BaseAlert { description: i18n.translate( 'xpack.monitoring.alerts.shardSize.actionVariables.shardIndex', { - defaultMessage: 'List of indices which are experiencing large shard size.', + defaultMessage: 'List of indices which are experiencing large average shard size.', } ), }, @@ -100,7 +100,7 @@ export class LargeShardSizeAlert extends BaseAlert { const { shardIndex, shardSize } = item.meta as IndexShardSizeUIMeta; return { text: i18n.translate('xpack.monitoring.alerts.shardSize.ui.firingMessage', { - defaultMessage: `The following index: #start_link{shardIndex}#end_link has a large shard size of: {shardSize}GB at #absolute`, + defaultMessage: `The following index: #start_link{shardIndex}#end_link has a large average shard size of: {shardSize}GB at #absolute`, values: { shardIndex, shardSize, diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts index f51e1cde47f8d0..c3e9f08c3b9490 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts @@ -69,13 +69,6 @@ export async function fetchIndexShardSize( }, aggs: { over_threshold: { - filter: { - range: { - 'index_stats.primaries.store.size_in_bytes': { - gt: threshold * gbMultiplier, - }, - }, - }, aggs: { index: { terms: { @@ -96,6 +89,7 @@ export async function fetchIndexShardSize( _source: { includes: [ '_index', + 'index_stats.shards.primaries', 'index_stats.primaries.store.size_in_bytes', 'source_node.name', 'source_node.uuid', @@ -123,7 +117,7 @@ export async function fetchIndexShardSize( if (!clusterBuckets.length) { return stats; } - + const thresholdBytes = threshold * gbMultiplier; for (const clusterBucket of clusterBuckets) { const indexBuckets = clusterBucket.over_threshold.index.buckets; const clusterUuid = clusterBucket.key; @@ -143,9 +137,25 @@ export async function fetchIndexShardSize( _source: { source_node: sourceNode, index_stats: indexStats }, } = topHit; - const { size_in_bytes: shardSizeBytes } = indexStats?.primaries?.store!; + if (!indexStats || !indexStats.primaries) { + continue; + } + + const { primaries: totalPrimaryShards } = indexStats.shards; + const { size_in_bytes: primaryShardSizeBytes = 0 } = indexStats.primaries.store || {}; + if (!primaryShardSizeBytes || !totalPrimaryShards) { + continue; + } + /** + * We can only calculate the average primary shard size at this point, since we don't have + * data (in .monitoring-es* indices) to give us individual shards. This might change in the future + */ const { name: nodeName, uuid: nodeId } = sourceNode; - const shardSize = +(shardSizeBytes! / gbMultiplier).toFixed(2); + const avgShardSize = primaryShardSizeBytes / totalPrimaryShards; + if (avgShardSize < thresholdBytes) { + continue; + } + const shardSize = +(avgShardSize / gbMultiplier).toFixed(2); stats.push({ shardIndex, shardSize, From dfca5d440c5cf5f2fb900d5427a2ca03b812331d Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Tue, 13 Apr 2021 16:02:55 -0500 Subject: [PATCH 55/90] Instances latency distribution chart tooltips and axis fixes (#95577) Fixes #88852 --- x-pack/plugins/apm/common/i18n.ts | 7 - x-pack/plugins/apm/common/service_nodes.ts | 15 ++ .../app/Main/route_config/index.tsx | 13 +- .../app/service_node_overview/index.tsx | 8 +- ...ice_overview_instances_chart_and_table.tsx | 16 +- .../get_columns.tsx | 10 +- .../custom_tooltip.stories.tsx | 181 +++++++++++++++ .../custom_tooltip.tsx | 214 ++++++++++++++++++ .../index.tsx | 53 ++++- ...ces_latency_distribution_chart.stories.tsx | 108 +++++++++ 10 files changed, 586 insertions(+), 39 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/custom_tooltip.stories.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/custom_tooltip.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/instances_latency_distribution_chart.stories.tsx diff --git a/x-pack/plugins/apm/common/i18n.ts b/x-pack/plugins/apm/common/i18n.ts index c5bbef0db244e4..8bce2acdf4dca8 100644 --- a/x-pack/plugins/apm/common/i18n.ts +++ b/x-pack/plugins/apm/common/i18n.ts @@ -13,10 +13,3 @@ export const NOT_AVAILABLE_LABEL = i18n.translate( defaultMessage: 'N/A', } ); - -export const UNIDENTIFIED_SERVICE_NODES_LABEL = i18n.translate( - 'xpack.apm.serviceNodeNameMissing', - { - defaultMessage: '(Empty)', - } -); diff --git a/x-pack/plugins/apm/common/service_nodes.ts b/x-pack/plugins/apm/common/service_nodes.ts index d744330f17b66f..ad75bd025069d4 100644 --- a/x-pack/plugins/apm/common/service_nodes.ts +++ b/x-pack/plugins/apm/common/service_nodes.ts @@ -5,4 +5,19 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; + export const SERVICE_NODE_NAME_MISSING = '_service_node_name_missing_'; + +const UNIDENTIFIED_SERVICE_NODES_LABEL = i18n.translate( + 'xpack.apm.serviceNodeNameMissing', + { + defaultMessage: '(Empty)', + } +); + +export function getServiceNodeName(serviceNodeName?: string) { + return serviceNodeName === SERVICE_NODE_NAME_MISSING || !serviceNodeName + ? UNIDENTIFIED_SERVICE_NODES_LABEL + : serviceNodeName; +} diff --git a/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx b/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx index a7cbd7a79b4a7f..0ed9c5c919ddb1 100644 --- a/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx @@ -9,8 +9,7 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { Redirect, RouteComponentProps } from 'react-router-dom'; import { ApmServiceContextProvider } from '../../../../context/apm_service/apm_service_context'; -import { UNIDENTIFIED_SERVICE_NODES_LABEL } from '../../../../../common/i18n'; -import { SERVICE_NODE_NAME_MISSING } from '../../../../../common/service_nodes'; +import { getServiceNodeName } from '../../../../../common/service_nodes'; import { APMRouteDefinition } from '../../../../application/routes'; import { toQuery } from '../../../shared/Links/url_helpers'; import { ErrorGroupDetails } from '../../ErrorGroupDetails'; @@ -294,15 +293,7 @@ export const routes: APMRouteDefinition[] = [ exact: true, path: '/services/:serviceName/nodes/:serviceNodeName/metrics', component: withApmServiceContext(ServiceNodeMetrics), - breadcrumb: ({ match }) => { - const { serviceNodeName } = match.params; - - if (serviceNodeName === SERVICE_NODE_NAME_MISSING) { - return UNIDENTIFIED_SERVICE_NODES_LABEL; - } - - return serviceNodeName || ''; - }, + breadcrumb: ({ match }) => getServiceNodeName(match.params.serviceNodeName), }, { exact: true, diff --git a/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx index fc218f3ba6df30..3d284de621ea38 100644 --- a/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx @@ -8,8 +8,10 @@ import { EuiFlexGroup, EuiPage, EuiPanel, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; -import { UNIDENTIFIED_SERVICE_NODES_LABEL } from '../../../../common/i18n'; -import { SERVICE_NODE_NAME_MISSING } from '../../../../common/service_nodes'; +import { + getServiceNodeName, + SERVICE_NODE_NAME_MISSING, +} from '../../../../common/service_nodes'; import { asDynamicBytes, asInteger, @@ -83,7 +85,7 @@ function ServiceNodeOverview({ serviceName }: ServiceNodeOverviewProps) { const { displayedName, tooltip } = name === SERVICE_NODE_NAME_MISSING ? { - displayedName: UNIDENTIFIED_SERVICE_NODES_LABEL, + displayedName: getServiceNodeName(name), tooltip: i18n.translate( 'xpack.apm.jvmsTable.explainServiceNodeNameMissing', { diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx index 13322b094c65ee..55eb2e3ddab732 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx @@ -13,19 +13,13 @@ import { useApmServiceContext } from '../../../context/apm_service/use_apm_servi import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; import { APIReturnType } from '../../../services/rest/createCallApmApi'; +import { InstancesLatencyDistributionChart } from '../../shared/charts/instances_latency_distribution_chart'; import { getTimeRangeComparison } from '../../shared/time_comparison/get_time_range_comparison'; import { ServiceOverviewInstancesTable, TableOptions, } from './service_overview_instances_table'; -// We're hiding this chart until these issues are resolved in the 7.13 timeframe: -// -// * [[APM] Tooltips for instances latency distribution chart](https://github.com/elastic/kibana/issues/88852) -// * [[APM] x-axis on the instance bubble chart is broken](https://github.com/elastic/kibana/issues/92631) -// -// import { InstancesLatencyDistributionChart } from '../../shared/charts/instances_latency_distribution_chart'; - interface ServiceOverviewInstancesChartAndTableProps { chartHeight: number; serviceName: string; @@ -215,13 +209,13 @@ export function ServiceOverviewInstancesChartAndTable({ return ( <> - {/* + - */} + { + const datum = (value.datum as unknown) as PrimaryStatsServiceInstanceItem; + return datum.latency ?? 0; + }) + ); + return getDurationFormatter(maxLatency); +} + +export default { + title: 'shared/charts/InstancesLatencyDistributionChart/CustomTooltip', + component: CustomTooltip, + decorators: [ + (Story: ComponentType) => ( + + + + ), + ], +}; + +export function Example(props: TooltipInfo) { + return ( + + ); +} +Example.args = { + header: { + seriesIdentifier: { + key: + 'groupId{__global__}spec{Instances}yAccessor{(index:0)}splitAccessors{}', + specId: 'Instances', + yAccessor: '(index:0)', + splitAccessors: {}, + seriesKeys: ['(index:0)'], + }, + valueAccessor: 'y1', + label: 'Instances', + value: 9.473837632998105, + formattedValue: '9.473837632998105', + markValue: null, + color: '#6092c0', + isHighlighted: false, + isVisible: true, + datum: { + serviceNodeName: + '2f3221afa3f00d3bc07069d69efd5bd4c1607be6155a204551c8fe2e2b5dd750', + errorRate: 0.03496503496503497, + latency: 1057231.4125874126, + throughput: 9.473837632998105, + cpuUsage: 0.000033333333333333335, + memoryUsage: 0.18701022939403547, + }, + }, + values: [ + { + seriesIdentifier: { + key: + 'groupId{__global__}spec{Instances}yAccessor{(index:0)}splitAccessors{}', + specId: 'Instances', + }, + valueAccessor: 'y1', + label: 'Instances', + value: 1057231.4125874126, + formattedValue: '1057231.4125874126', + markValue: null, + color: '#6092c0', + isHighlighted: true, + isVisible: true, + datum: { + serviceNodeName: + '2f3221afa3f00d3bc07069d69efd5bd4c1607be6155a204551c8fe2e2b5dd750', + errorRate: 0.03496503496503497, + latency: 1057231.4125874126, + throughput: 9.473837632998105, + cpuUsage: 0.000033333333333333335, + memoryUsage: 0.18701022939403547, + }, + }, + ], +} as TooltipInfo; + +export function MultipleInstances(props: TooltipInfo) { + return ( + + ); +} +MultipleInstances.args = { + header: { + seriesIdentifier: { + key: + 'groupId{__global__}spec{Instances}yAccessor{(index:0)}splitAccessors{}', + specId: 'Instances', + yAccessor: '(index:0)', + splitAccessors: {}, + seriesKeys: ['(index:0)'], + }, + valueAccessor: 'y1', + label: 'Instances', + value: 9.606338858634443, + formattedValue: '9.606338858634443', + markValue: null, + color: '#6092c0', + isHighlighted: false, + isVisible: true, + datum: { + serviceNodeName: + '3b50ad269c45be69088905c4b355cc75ab94aaac1b35432bb752050438f4216f', + errorRate: 0.006896551724137931, + latency: 56465.53793103448, + throughput: 9.606338858634443, + cpuUsage: 0.0001, + memoryUsage: 0.1872131360014741, + }, + }, + values: [ + { + seriesIdentifier: { + key: + 'groupId{__global__}spec{Instances}yAccessor{(index:0)}splitAccessors{}', + specId: 'Instances', + }, + valueAccessor: 'y1', + label: 'Instances', + value: 56465.53793103448, + formattedValue: '56465.53793103448', + markValue: null, + color: '#6092c0', + isHighlighted: true, + isVisible: true, + datum: { + serviceNodeName: + '3b50ad269c45be69088905c4b355cc75ab94aaac1b35432bb752050438f4216f', + errorRate: 0.006896551724137931, + latency: 56465.53793103448, + throughput: 9.606338858634443, + cpuUsage: 0.0001, + memoryUsage: 0.1872131360014741, + }, + }, + { + seriesIdentifier: { + key: + 'groupId{__global__}spec{Instances}yAccessor{(index:0)}splitAccessors{}', + specId: 'Instances', + }, + valueAccessor: 'y1', + label: 'Instances', + value: 56465.53793103448, + formattedValue: '56465.53793103448', + markValue: null, + color: '#6092c0', + isHighlighted: true, + isVisible: true, + datum: { + serviceNodeName: + '3b50ad269c45be69088905c4b355cc75ab94aaac1b35432bb752050438f4216f (2)', + errorRate: 0.006896551724137931, + latency: 56465.53793103448, + throughput: 9.606338858634443, + cpuUsage: 0.0001, + memoryUsage: 0.1872131360014741, + }, + }, + ], +} as TooltipInfo; diff --git a/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/custom_tooltip.tsx b/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/custom_tooltip.tsx new file mode 100644 index 00000000000000..2280fa91a659c0 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/custom_tooltip.tsx @@ -0,0 +1,214 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TooltipInfo } from '@elastic/charts'; +import { EuiIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { getServiceNodeName } from '../../../../../common/service_nodes'; +import { + asTransactionRate, + TimeFormatter, +} from '../../../../../common/utils/formatters'; +import { useTheme } from '../../../../hooks/use_theme'; +import { PrimaryStatsServiceInstanceItem } from '../../../app/service_overview/service_overview_instances_chart_and_table'; + +const latencyLabel = i18n.translate( + 'xpack.apm.instancesLatencyDistributionChartTooltipLatencyLabel', + { + defaultMessage: 'Latency', + } +); + +const throughputLabel = i18n.translate( + 'xpack.apm.instancesLatencyDistributionChartTooltipThroughputLabel', + { + defaultMessage: 'Throughput', + } +); + +const clickToFilterDescription = i18n.translate( + 'xpack.apm.instancesLatencyDistributionChartTooltipClickToFilterDescription', + { defaultMessage: 'Click to filter by instance' } +); + +/** + * Tooltip for a single instance + */ +function SingleInstanceCustomTooltip({ + latencyFormatter, + values, +}: { + latencyFormatter: TimeFormatter; + values: TooltipInfo['values']; +}) { + const value = values[0]; + const { color } = value; + const datum = (value.datum as unknown) as PrimaryStatsServiceInstanceItem; + const { latency, serviceNodeName, throughput } = datum; + + return ( + <> +
+ {getServiceNodeName(serviceNodeName)} +
+
+
+
+
+
+
+ {latencyLabel} + + {latencyFormatter(latency).formatted} + +
+
+
+
+
+
+
+ {throughputLabel} + + {asTransactionRate(throughput)} + +
+
+
+ + ); +} + +/** + * Tooltip for a multiple instances + */ +function MultipleInstanceCustomTooltip({ + latencyFormatter, + values, +}: TooltipInfo & { latencyFormatter: TimeFormatter }) { + const theme = useTheme(); + + return ( + <> +
+ {i18n.translate( + 'xpack.apm.instancesLatencyDistributionChartTooltipInstancesTitle', + { + defaultMessage: + '{instancesCount} {instancesCount, plural, one {instance} other {instances}}', + values: { instancesCount: values.length }, + } + )} +
+ {values.map((value) => { + const { color } = value; + const datum = (value.datum as unknown) as PrimaryStatsServiceInstanceItem; + const { latency, serviceNodeName, throughput } = datum; + return ( +
+
+
+
+
+
+ + {getServiceNodeName(serviceNodeName)} + +
+
+
+
+
+
+
+ {latencyLabel} + + {latencyFormatter(latency).formatted} + +
+
+
+
+
+
+
+ {throughputLabel} + + {asTransactionRate(throughput)} + +
+
+
+ ); + })} + + ); +} + +/** + * Custom tooltip for instances latency distribution chart. + * + * The styling provided here recreates that in the Elastic Charts tooltip: https://github.com/elastic/elastic-charts/blob/58e6b5fbf77f4471d2a9a41c45a61f79ebd89b65/src/components/tooltip/tooltip.tsx + * + * We probably won't need to do all of this once https://github.com/elastic/elastic-charts/issues/615 is completed. + */ +export function CustomTooltip( + props: TooltipInfo & { latencyFormatter: TimeFormatter } +) { + const { values } = props; + const theme = useTheme(); + + return ( +
+ {values.length > 1 ? ( + + ) : ( + + )} +
+ {clickToFilterDescription} +
+
+ ); +} diff --git a/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx index 5bcf0d161653ee..57ecbd4ca0b78b 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx @@ -9,14 +9,21 @@ import { Axis, BubbleSeries, Chart, + ElementClickListener, + GeometryValue, Position, ScaleType, Settings, + TooltipInfo, + TooltipProps, + TooltipType, } from '@elastic/charts'; import { EuiPanel, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; +import { useHistory } from 'react-router-dom'; import { useChartTheme } from '../../../../../../observability/public'; +import { SERVICE_NODE_NAME } from '../../../../../common/elasticsearch_fieldnames'; import { asTransactionRate, getDurationFormatter, @@ -24,10 +31,12 @@ import { import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; import { useTheme } from '../../../../hooks/use_theme'; import { PrimaryStatsServiceInstanceItem } from '../../../app/service_overview/service_overview_instances_chart_and_table'; +import * as urlHelpers from '../../Links/url_helpers'; import { ChartContainer } from '../chart_container'; import { getResponseTimeTickFormatter } from '../transaction_charts/helper'; +import { CustomTooltip } from './custom_tooltip'; -interface InstancesLatencyDistributionChartProps { +export interface InstancesLatencyDistributionChartProps { height: number; items?: PrimaryStatsServiceInstanceItem[]; status: FETCH_STATUS; @@ -38,6 +47,7 @@ export function InstancesLatencyDistributionChart({ items = [], status, }: InstancesLatencyDistributionChartProps) { + const history = useHistory(); const hasData = items.length > 0; const theme = useTheme(); @@ -51,6 +61,43 @@ export function InstancesLatencyDistributionChart({ const maxLatency = Math.max(...items.map((item) => item.latency ?? 0)); const latencyFormatter = getDurationFormatter(maxLatency); + const tooltip: TooltipProps = { + type: TooltipType.Follow, + snap: false, + customTooltip: (props: TooltipInfo) => ( + + ), + }; + + /** + * Handle click events on the items. + * + * Due to how we handle filtering by using the kuery bar, it's difficult to + * modify existing queries. If you have an existing query in the bar, this will + * wipe it out. This is ok for now, since we probably will be replacing this + * interaction with something nicer in a future release. + * + * The event object has an array two items for each point, one of which has + * the serviceNodeName, so we flatten the list and get the items we need to + * form a query. + */ + const handleElementClick: ElementClickListener = (event) => { + const serviceNodeNamesQuery = event + .flat() + .flatMap((value) => (value as GeometryValue).datum?.serviceNodeName) + .filter((serviceNodeName) => !!serviceNodeName) + .map((serviceNodeName) => `${SERVICE_NODE_NAME}:"${serviceNodeName}"`) + .join(' OR '); + + urlHelpers.push(history, { query: { kuery: serviceNodeNamesQuery } }); + }; + + // With a linear scale, if all the instances have similar throughput (or if + // there's just a single instance) they'll show along the origin. Make sure + // the x-axis domain is [0, maxThroughput]. + const maxThroughput = Math.max(...items.map((item) => item.throughput ?? 0)); + const xDomain = { min: 0, max: maxThroughput }; + return ( @@ -64,9 +111,11 @@ export function InstancesLatencyDistributionChart({ ( + + + + ), + ], +}; + +export function Example({ items }: InstancesLatencyDistributionChartProps) { + return ( + + ); +} +Example.args = { + items: [ + { + serviceNodeName: + '3f67bfc39c7891dc0c5657befb17bf58c19cf10f99472cf8df263c8e5bb1c766', + latency: 15802930.92133213, + throughput: 0.4019360641691481, + }, + { + serviceNodeName: + 'd52c64bea9327f3e960ac1cb63c1b7ea922e3cb3d76ab9b254e57a7cb2f760a0', + latency: 8296442.578550679, + throughput: 0.3932978392703585, + }, + { + serviceNodeName: + '797e0a906ad342223468ca51b663e1af8bdeb40bab376c46c7f7fa2021349290', + latency: 34842576.51204916, + throughput: 0.3353931699532713, + }, + { + serviceNodeName: + '21e1c648bd73434a8a1bf6e849817930e8b43eacf73a5c39c30520ee3b79d8c0', + latency: 40713854.354498595, + throughput: 0.32947224189485164, + }, + { + serviceNodeName: + 'a1c99c8675372af4c74bb01cc48e75989faa6f010a4ccb027df1c410dde0c72c', + latency: 18565471.348388012, + throughput: 0.3261219384041683, + }, + { + serviceNodeName: '_service_node_name_missing_', + latency: 20065471.348388012, + throughput: 0.3261219384041683, + }, + ], +} as InstancesLatencyDistributionChartProps; + +export function SimilarThroughputInstances({ + items, +}: InstancesLatencyDistributionChartProps) { + return ( + + ); +} +SimilarThroughputInstances.args = { + items: [ + { + serviceNodeName: + '21e1c648bd73434a8a1bf6e849817930e8b43eacf73a5c39c30520ee3b79d8c0', + latency: 40713854.354498595, + throughput: 0.3261219384041683, + }, + { + serviceNodeName: + 'a1c99c8675372af4c74bb01cc48e75989faa6f010a4ccb027df1c410dde0c72c', + latency: 18565471.348388012, + throughput: 0.3261219384041683, + }, + { + serviceNodeName: '_service_node_name_missing_', + latency: 20065471.348388012, + throughput: 0.3261219384041683, + }, + ], +} as InstancesLatencyDistributionChartProps; From 71672c4c3830f4fd45cb7a7da7de64f7316e6659 Mon Sep 17 00:00:00 2001 From: Byron Hulcher Date: Tue, 13 Apr 2021 21:15:37 -0400 Subject: [PATCH 56/90] [App Search] Migrate expanded rows for meta engines table in Engines Overview (#96251) * Pull out columns to be re-used for MetaEnginesTable * Add route to get source engines for meta engines * New MetaEnginesTableLogic * New MetaEnginesTable component * Remove isMeta prop from EnginesTable * Swap EnginesTable with MetaEnginesTable in EnginesOverview for meta engines * Missing test for MetaEnginesTableNameColumnContent * Created new /app_search/components/engines/components/tables directory * Moving columns to shared_columns.tsx file * Updates to MetaEnginesTableExpandedRow and MetaEnginesTableNameColumnContent * Fixes to EnginesTable, MetaEnginesTable, MetaEnginesTableLogic * Remove flatten import * Fix i18n * PR Feedback * DRY out shared engine link helpers * DRY out shared ACTIONS_COLUMN * Tests: DRY out shared columns/props tests + update to account for 2 previous DRY commits (e.g. deleteEngine mock) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Constance Chen --- .../tables/__mocks__/engines_logic.mock.ts | 10 + .../tables/engine_link_helpers.test.tsx | 47 ++++ .../components/tables/engine_link_helpers.tsx | 36 +++ .../components/tables/engines_table.test.tsx | 85 ++++++ .../components/tables/engines_table.tsx | 74 +++++ .../tables/meta_engines_table.test.tsx | 100 +++++++ .../components/tables/meta_engines_table.tsx | 113 ++++++++ .../meta_engines_table_expanded_row.scss | 21 ++ .../meta_engines_table_expanded_row.test.tsx | 69 +++++ .../meta_engines_table_expanded_row.tsx | 69 +++++ .../tables/meta_engines_table_logic.test.ts | 255 ++++++++++++++++++ .../tables/meta_engines_table_logic.ts | 127 +++++++++ ...engines_table_name_column_content.test.tsx | 154 +++++++++++ ...meta_engines_table_name_column_content.tsx | 67 +++++ .../components/tables/shared_columns.tsx | 127 +++++++++ .../components/tables/test_helpers/index.ts | 9 + .../tables/test_helpers/shared_columns.tsx | 111 ++++++++ .../tables/test_helpers/shared_props.tsx | 42 +++ .../engines/components/tables/types.ts | 25 ++ .../engines/components/tables/utils.test.ts | 101 +++++++ .../engines/components/tables/utils.ts | 28 ++ .../components/engines/constants.ts | 5 + .../engines/engines_overview.test.tsx | 15 +- .../components/engines/engines_overview.tsx | 17 +- .../components/engines/engines_table.test.tsx | 245 ----------------- .../components/engines/engines_table.tsx | 210 --------------- .../server/routes/app_search/engines.test.ts | 43 +++ .../server/routes/app_search/engines.ts | 17 ++ 28 files changed, 1751 insertions(+), 471 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/__mocks__/engines_logic.mock.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engine_link_helpers.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engine_link_helpers.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engines_table.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engines_table.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_expanded_row.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_expanded_row.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_expanded_row.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_name_column_content.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_name_column_content.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/shared_columns.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/test_helpers/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/test_helpers/shared_columns.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/test_helpers/shared_props.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/types.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/utils.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/utils.ts delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.test.tsx delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/__mocks__/engines_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/__mocks__/engines_logic.mock.ts new file mode 100644 index 00000000000000..4ab9137436ffe2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/__mocks__/engines_logic.mock.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +jest.mock('../../../../engines', () => ({ + EnginesLogic: { actions: { deleteEngine: jest.fn() } }, +})); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engine_link_helpers.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engine_link_helpers.test.tsx new file mode 100644 index 00000000000000..5d91c724068e75 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engine_link_helpers.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mockKibanaValues, mockTelemetryActions } from '../../../../../__mocks__'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiLinkTo } from '../../../../../shared/react_router_helpers'; + +import { navigateToEngine, renderEngineLink } from './engine_link_helpers'; + +describe('navigateToEngine', () => { + const { navigateToUrl } = mockKibanaValues; + const { sendAppSearchTelemetry } = mockTelemetryActions; + + it('sends the user to the engine page and triggers a telemetry event', () => { + navigateToEngine('engine-a'); + expect(navigateToUrl).toHaveBeenCalledWith('/engines/engine-a'); + expect(sendAppSearchTelemetry).toHaveBeenCalledWith({ + action: 'clicked', + metric: 'engine_table_link', + }); + }); +}); + +describe('renderEngineLink', () => { + const { sendAppSearchTelemetry } = mockTelemetryActions; + + it('renders a link to the engine with telemetry', () => { + const wrapper = shallow(
{renderEngineLink('engine-b')}
); + const link = wrapper.find(EuiLinkTo); + + expect(link.prop('to')).toEqual('/engines/engine-b'); + + link.simulate('click'); + expect(sendAppSearchTelemetry).toHaveBeenCalledWith({ + action: 'clicked', + metric: 'engine_table_link', + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engine_link_helpers.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engine_link_helpers.tsx new file mode 100644 index 00000000000000..a3350d1ef9939c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engine_link_helpers.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { KibanaLogic } from '../../../../../shared/kibana'; +import { EuiLinkTo } from '../../../../../shared/react_router_helpers'; +import { TelemetryLogic } from '../../../../../shared/telemetry'; +import { ENGINE_PATH } from '../../../../routes'; +import { generateEncodedPath } from '../../../../utils/encode_path_params'; + +const sendEngineTableLinkClickTelemetry = () => { + TelemetryLogic.actions.sendAppSearchTelemetry({ + action: 'clicked', + metric: 'engine_table_link', + }); +}; + +export const navigateToEngine = (engineName: string) => { + sendEngineTableLinkClickTelemetry(); + KibanaLogic.values.navigateToUrl(generateEncodedPath(ENGINE_PATH, { engineName })); +}; + +export const renderEngineLink = (engineName: string) => ( + + {engineName} + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engines_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engines_table.test.tsx new file mode 100644 index 00000000000000..8d3b4b2a5e6cac --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engines_table.test.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mountWithIntl, setMockValues } from '../../../../../__mocks__'; +import '../../../../../__mocks__/enterprise_search_url.mock'; +import './__mocks__/engines_logic.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiBasicTable } from '@elastic/eui'; + +import { EngineDetails } from '../../../engine/types'; + +import { EnginesTable } from './engines_table'; + +import { runSharedColumnsTests, runSharedPropsTests } from './test_helpers'; + +describe('EnginesTable', () => { + const data = [ + { + name: 'test-engine', + created_at: 'Fri, 1 Jan 1970 12:00:00 +0000', + language: 'English', + isMeta: false, + document_count: 99999, + field_count: 10, + } as EngineDetails, + ]; + const props = { + items: data, + loading: false, + pagination: { + pageIndex: 0, + pageSize: 10, + totalItemCount: 1, + hidePerPageOptions: true, + }, + onChange: () => {}, + }; + setMockValues({ myRole: { canManageEngines: false } }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiBasicTable)).toHaveLength(1); + }); + + describe('columns', () => { + const wrapper = shallow(); + const tableContent = mountWithIntl() + .find(EuiBasicTable) + .text(); + runSharedColumnsTests(wrapper, tableContent); + }); + + describe('language column', () => { + it('renders language when set', () => { + const wrapper = mountWithIntl( + + ); + expect(wrapper.find(EuiBasicTable).text()).toContain('German'); + }); + + it('renders the language as Universal if no language is set', () => { + const wrapper = mountWithIntl( + + ); + expect(wrapper.find(EuiBasicTable).text()).toContain('Universal'); + }); + }); + + describe('passed props', () => { + const wrapper = shallow(); + runSharedPropsTests(wrapper); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engines_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engines_table.tsx new file mode 100644 index 00000000000000..563e272a4a7303 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/engines_table.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useValues } from 'kea'; + +import { EuiBasicTable, EuiBasicTableColumn, EuiTableFieldDataColumnType } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { AppLogic } from '../../../../app_logic'; +import { UNIVERSAL_LANGUAGE } from '../../../../constants'; +import { EngineDetails } from '../../../engine/types'; + +import { renderEngineLink } from './engine_link_helpers'; +import { + ACTIONS_COLUMN, + CREATED_AT_COLUMN, + DOCUMENT_COUNT_COLUMN, + FIELD_COUNT_COLUMN, + NAME_COLUMN, +} from './shared_columns'; +import { EnginesTableProps } from './types'; + +const LANGUAGE_COLUMN: EuiTableFieldDataColumnType = { + field: 'language', + name: i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.table.column.language', { + defaultMessage: 'Language', + }), + dataType: 'string', + render: (language: string) => language || UNIVERSAL_LANGUAGE, +}; + +export const EnginesTable: React.FC = ({ + items, + loading, + noItemsMessage, + pagination, + onChange, +}) => { + const { + myRole: { canManageEngines }, + } = useValues(AppLogic); + + const columns: Array> = [ + { + ...NAME_COLUMN, + render: (name: string) => renderEngineLink(name), + }, + CREATED_AT_COLUMN, + LANGUAGE_COLUMN, + DOCUMENT_COUNT_COLUMN, + FIELD_COUNT_COLUMN, + ]; + + if (canManageEngines) { + columns.push(ACTIONS_COLUMN); + } + + return ( + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table.test.tsx new file mode 100644 index 00000000000000..430539c10bbf37 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table.test.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mountWithIntl, setMockValues } from '../../../../../__mocks__'; +import '../../../../../__mocks__/enterprise_search_url.mock'; +import './__mocks__/engines_logic.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiBasicTable } from '@elastic/eui'; + +import { EngineDetails } from '../../../engine/types'; + +import { MetaEnginesTable } from './meta_engines_table'; +import { MetaEnginesTableExpandedRow } from './meta_engines_table_expanded_row'; +import { MetaEnginesTableNameColumnContent } from './meta_engines_table_name_column_content'; + +import { runSharedColumnsTests, runSharedPropsTests } from './test_helpers'; + +describe('MetaEnginesTable', () => { + const data = [ + { + name: 'test-engine', + created_at: 'Fri, 1 Jan 1970 12:00:00 +0000', + isMeta: true, + document_count: 99999, + field_count: 10, + includedEngines: [{ name: 'source-engine-1' }, { name: 'source-engine-2' }], + } as EngineDetails, + ]; + const props = { + items: data, + loading: false, + pagination: { + pageIndex: 0, + pageSize: 10, + totalItemCount: 1, + hidePerPageOptions: true, + }, + onChange: () => {}, + }; + + const DEFAULT_VALUES = { + myRole: { + canManageMetaEngines: false, + }, + expandedSourceEngines: {}, + hideRow: jest.fn(), + fetchOrDisplayRow: jest.fn(), + }; + setMockValues(DEFAULT_VALUES); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiBasicTable)).toHaveLength(1); + }); + + describe('columns', () => { + const wrapper = shallow(); + const tableContent = mountWithIntl() + .find(EuiBasicTable) + .text(); + runSharedColumnsTests(wrapper, tableContent, DEFAULT_VALUES); + }); + + describe('passed props', () => { + const wrapper = shallow(); + runSharedPropsTests(wrapper); + }); + + describe('expanded source engines', () => { + it('is hidden by default', () => { + const wrapper = shallow(); + const table = wrapper.find(EuiBasicTable).dive(); + + expect(table.find(MetaEnginesTableNameColumnContent)).toHaveLength(1); + expect(table.find(MetaEnginesTableExpandedRow)).toHaveLength(0); + }); + + it('is visible when the row has been expanded', () => { + setMockValues({ + ...DEFAULT_VALUES, + expandedSourceEngines: { 'test-engine': true }, + }); + const wrapper = shallow(); + const table = wrapper.find(EuiBasicTable); + expect(table.dive().find(MetaEnginesTableExpandedRow)).toHaveLength(1); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table.tsx new file mode 100644 index 00000000000000..f99dc7e15eaec5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { ReactNode, useMemo } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; + +import { AppLogic } from '../../../../app_logic'; +import { EngineDetails } from '../../../engine/types'; + +import { MetaEnginesTableExpandedRow } from './meta_engines_table_expanded_row'; +import { MetaEnginesTableLogic } from './meta_engines_table_logic'; +import { MetaEnginesTableNameColumnContent } from './meta_engines_table_name_column_content'; +import { + ACTIONS_COLUMN, + BLANK_COLUMN, + CREATED_AT_COLUMN, + DOCUMENT_COUNT_COLUMN, + FIELD_COUNT_COLUMN, + NAME_COLUMN, +} from './shared_columns'; +import { EnginesTableProps } from './types'; +import { getConflictingEnginesSet } from './utils'; + +interface IItemIdToExpandedRowMap { + [id: string]: ReactNode; +} + +export interface ConflictingEnginesSets { + [key: string]: Set; +} + +export const MetaEnginesTable: React.FC = ({ + items, + loading, + noItemsMessage, + pagination, + onChange, +}) => { + const { expandedSourceEngines } = useValues(MetaEnginesTableLogic); + const { hideRow, fetchOrDisplayRow } = useActions(MetaEnginesTableLogic); + const { + myRole: { canManageMetaEngines }, + } = useValues(AppLogic); + + const conflictingEnginesSets: ConflictingEnginesSets = useMemo( + () => + items.reduce((accumulator, metaEngine) => { + return { + ...accumulator, + [metaEngine.name]: getConflictingEnginesSet(metaEngine), + }; + }, {}), + [items] + ); + + const itemIdToExpandedRowMap: IItemIdToExpandedRowMap = useMemo( + () => + Object.keys(expandedSourceEngines).reduce((accumulator, engineName) => { + return { + ...accumulator, + [engineName]: ( + + ), + }; + }, {}), + [expandedSourceEngines, conflictingEnginesSets] + ); + + const columns: Array> = [ + { + ...NAME_COLUMN, + render: (_, item: EngineDetails) => ( + + ), + }, + CREATED_AT_COLUMN, + BLANK_COLUMN, + DOCUMENT_COUNT_COLUMN, + FIELD_COUNT_COLUMN, + ]; + + if (canManageMetaEngines) { + columns.push(ACTIONS_COLUMN); + } + + return ( + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_expanded_row.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_expanded_row.scss new file mode 100644 index 00000000000000..e6f627458f43e7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_expanded_row.scss @@ -0,0 +1,21 @@ +.metaEnginesSourceEnginesTable { + margin: (-$euiSizeS) (-$euiSizeS) $euiSizeS (-$euiSizeS); + + thead { + display: none; + } + + @include euiBreakpoint('l', 'xl') { + .euiTableRowCell { + border-top: none; + } + + .euiTitle { + display: none; + } + } + + .euiTableHeaderMobile { + display: none + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_expanded_row.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_expanded_row.test.tsx new file mode 100644 index 00000000000000..dcaa1a2b7c2461 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_expanded_row.test.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mountWithIntl } from '../../../../../__mocks__'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiBasicTable, EuiHealth } from '@elastic/eui'; + +import { EngineDetails } from '../../../engine/types'; + +import { MetaEnginesTableExpandedRow } from './meta_engines_table_expanded_row'; + +const SOURCE_ENGINES = [ + { + name: 'source-engine-1', + created_at: 'Fri, 1 Jan 1970 12:00:00 +0000', + language: 'English', + isMeta: true, + document_count: 99999, + field_count: 10, + }, + { + name: 'source-engine-2', + created_at: 'Fri, 1 Jan 1970 12:00:00 +0000', + language: 'English', + isMeta: true, + document_count: 55555, + field_count: 7, + }, +] as EngineDetails[]; + +describe('MetaEnginesTableExpandedRow', () => { + it('contains relevant source engine information', () => { + const wrapper = mountWithIntl( + + ); + const table = wrapper.find(EuiBasicTable); + + expect(table).toHaveLength(1); + + const tableContent = table.text(); + expect(tableContent).toContain('source-engine-1'); + expect(tableContent).toContain('99,999'); + expect(tableContent).toContain('10'); + + expect(tableContent).toContain('source-engine-2'); + expect(tableContent).toContain('55,555'); + expect(tableContent).toContain('7'); + }); + + it('indicates when a meta-engine has conflicts', () => { + const wrapper = shallow( + + ); + + const table = wrapper.find(EuiBasicTable); + expect(table.dive().find(EuiHealth)).toHaveLength(2); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_expanded_row.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_expanded_row.tsx new file mode 100644 index 00000000000000..0f974581ca73cd --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_expanded_row.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiBasicTable, EuiHealth, EuiTitle } from '@elastic/eui'; + +import { EngineDetails } from '../../../engine/types'; +import { SOURCE_ENGINES_TITLE } from '../../constants'; + +import { + BLANK_COLUMN, + CREATED_AT_COLUMN, + DOCUMENT_COUNT_COLUMN, + FIELD_COUNT_COLUMN, + NAME_COLUMN, +} from './shared_columns'; + +import './meta_engines_table_expanded_row.scss'; + +interface MetaEnginesTableExpandedRowProps { + sourceEngines: EngineDetails[]; + conflictingEngines: Set; +} + +export const MetaEnginesTableExpandedRow: React.FC = ({ + sourceEngines, + conflictingEngines, +}) => ( +
+ +

{SOURCE_ENGINES_TITLE}

+
+ ( + <> + {conflictingEngines.has(engineDetails.name) ? ( + {engineDetails.field_count} + ) : ( + engineDetails.field_count + )} + + ), + }, + BLANK_COLUMN, + ]} + /> +
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_logic.test.ts new file mode 100644 index 00000000000000..b90207331ffd66 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_logic.test.ts @@ -0,0 +1,255 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../../../__mocks__'; + +import { nextTick } from '@kbn/test/jest'; + +import { EngineDetails } from '../../../engine/types'; + +import { MetaEnginesTableLogic } from './meta_engines_table_logic'; + +describe('MetaEnginesTableLogic', () => { + const DEFAULT_VALUES = { + expandedRows: {}, + sourceEngines: {}, + expandedSourceEngines: {}, + }; + + const SOURCE_ENGINES = [ + { + name: 'source-engine-1', + }, + { + name: 'source-engine-2', + }, + ] as EngineDetails[]; + + const META_ENGINES = [ + { + name: 'test-engine-1', + includedEngines: SOURCE_ENGINES, + }, + { + name: 'test-engine-2', + includedEngines: SOURCE_ENGINES, + }, + ] as EngineDetails[]; + + const DEFAULT_PROPS = { + metaEngines: [...SOURCE_ENGINES, ...META_ENGINES] as EngineDetails[], + }; + + const { http } = mockHttpValues; + const { mount } = new LogicMounter(MetaEnginesTableLogic); + const { flashAPIErrors } = mockFlashMessageHelpers; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('has expected default values', async () => { + mount({}, DEFAULT_PROPS); + expect(MetaEnginesTableLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('reducers', () => { + describe('expandedRows', () => { + it('displayRow adds an expanded row entry for provided itemId', () => { + mount(DEFAULT_VALUES, DEFAULT_PROPS); + MetaEnginesTableLogic.actions.displayRow('source-engine-1'); + + expect(MetaEnginesTableLogic.values.expandedRows).toEqual({ + 'source-engine-1': true, + }); + }); + + it('hideRow removes any expanded row entry for provided itemId', () => { + mount({ ...DEFAULT_VALUES, expandedRows: { 'source-engine-1': true } }, DEFAULT_PROPS); + + MetaEnginesTableLogic.actions.hideRow('source-engine-1'); + + expect(MetaEnginesTableLogic.values.expandedRows).toEqual({}); + }); + }); + + it('sourceEngines is updated by addSourceEngines', () => { + mount({ + ...DEFAULT_VALUES, + sourceEngines: { + 'test-engine-1': [ + { name: 'source-engine-1' }, + { name: 'source-engine-2' }, + ] as EngineDetails[], + }, + }); + + MetaEnginesTableLogic.actions.addSourceEngines({ + 'test-engine-2': [ + { name: 'source-engine-1' }, + { name: 'source-engine-2' }, + ] as EngineDetails[], + }); + + expect(MetaEnginesTableLogic.values.sourceEngines).toEqual({ + 'test-engine-1': [{ name: 'source-engine-1' }, { name: 'source-engine-2' }], + 'test-engine-2': [{ name: 'source-engine-1' }, { name: 'source-engine-2' }], + }); + }); + }); + + describe('listeners', () => { + describe('fetchOrDisplayRow', () => { + it('calls displayRow when it already has data for the itemId', () => { + mount({ + ...DEFAULT_VALUES, + sourceEngines: { + 'test-engine-1': [ + { name: 'source-engine-1' }, + { name: 'source-engine-2' }, + ] as EngineDetails[], + }, + }); + jest.spyOn(MetaEnginesTableLogic.actions, 'displayRow'); + + MetaEnginesTableLogic.actions.fetchOrDisplayRow('test-engine-1'); + + expect(MetaEnginesTableLogic.actions.displayRow).toHaveBeenCalled(); + }); + + it('calls fetchSourceEngines when it needs to fetch data for the itemId', () => { + http.get.mockReturnValueOnce( + Promise.resolve({ + meta: { + page: { + total_pages: 1, + }, + }, + results: [{ name: 'source-engine-1' }, { name: 'source-engine-2' }], + }) + ); + mount(); + jest.spyOn(MetaEnginesTableLogic.actions, 'fetchSourceEngines'); + + MetaEnginesTableLogic.actions.fetchOrDisplayRow('test-engine-1'); + + expect(MetaEnginesTableLogic.actions.fetchSourceEngines).toHaveBeenCalled(); + }); + }); + + describe('fetchSourceEngines', () => { + it('calls addSourceEngines and displayRow when it has retrieved all pages', async () => { + mount(); + http.get.mockReturnValueOnce( + Promise.resolve({ + meta: { + page: { + total_pages: 1, + }, + }, + results: [{ name: 'source-engine-1' }, { name: 'source-engine-2' }], + }) + ); + jest.spyOn(MetaEnginesTableLogic.actions, 'displayRow'); + jest.spyOn(MetaEnginesTableLogic.actions, 'addSourceEngines'); + + MetaEnginesTableLogic.actions.fetchSourceEngines('test-engine-1'); + await nextTick(); + + expect(http.get).toHaveBeenCalledWith( + '/api/app_search/engines/test-engine-1/source_engines', + { + query: { + 'page[current]': 1, + 'page[size]': 25, + }, + } + ); + expect(MetaEnginesTableLogic.actions.addSourceEngines).toHaveBeenCalledWith({ + 'test-engine-1': [{ name: 'source-engine-1' }, { name: 'source-engine-2' }], + }); + expect(MetaEnginesTableLogic.actions.displayRow).toHaveBeenCalledWith('test-engine-1'); + }); + + it('display a flash message on error', async () => { + http.get.mockReturnValueOnce(Promise.reject()); + mount(); + + MetaEnginesTableLogic.actions.fetchSourceEngines('test-engine-1'); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledTimes(1); + }); + + it('recursively fetches a number of pages', async () => { + mount(); + jest.spyOn(MetaEnginesTableLogic.actions, 'addSourceEngines'); + + // First page + http.get.mockReturnValueOnce( + Promise.resolve({ + meta: { + page: { + total_pages: 2, + }, + }, + results: [{ name: 'source-engine-1' }], + }) + ); + + // Second and final page + http.get.mockReturnValueOnce( + Promise.resolve({ + meta: { + page: { + total_pages: 2, + }, + }, + results: [{ name: 'source-engine-2' }], + }) + ); + + MetaEnginesTableLogic.actions.fetchSourceEngines('test-engine-1'); + await nextTick(); + + expect(MetaEnginesTableLogic.actions.addSourceEngines).toHaveBeenCalledWith({ + 'test-engine-1': [ + // First page + { name: 'source-engine-1' }, + // Second and final page + { name: 'source-engine-2' }, + ], + }); + }); + }); + }); + + describe('selectors', () => { + it('expandedSourceEngines includes all source engines that have been expanded ', () => { + mount({ + ...DEFAULT_VALUES, + sourceEngines: { + 'test-engine-1': [ + { name: 'source-engine-1' }, + { name: 'source-engine-2' }, + ] as EngineDetails[], + 'test-engine-2': [ + { name: 'source-engine-1' }, + { name: 'source-engine-2' }, + ] as EngineDetails[], + }, + expandedRows: { + 'test-engine-1': true, + }, + }); + + expect(MetaEnginesTableLogic.values.expandedSourceEngines).toEqual({ + 'test-engine-1': [{ name: 'source-engine-1' }, { name: 'source-engine-2' }], + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_logic.ts new file mode 100644 index 00000000000000..04e1ee5c1b61ae --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_logic.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kea, MakeLogicType } from 'kea'; + +import { Meta } from '../../../../../../../common/types'; +import { flashAPIErrors } from '../../../../../shared/flash_messages'; + +import { HttpLogic } from '../../../../../shared/http'; + +import { EngineDetails } from '../../../engine/types'; + +interface MetaEnginesTableValues { + expandedRows: { [id: string]: boolean }; + sourceEngines: { [id: string]: EngineDetails[] }; + expandedSourceEngines: { [id: string]: EngineDetails[] }; +} + +interface MetaEnginesTableActions { + addSourceEngines( + sourceEngines: MetaEnginesTableValues['sourceEngines'] + ): { sourceEngines: MetaEnginesTableValues['sourceEngines'] }; + displayRow(itemId: string): { itemId: string }; + fetchOrDisplayRow(itemId: string): { itemId: string }; + fetchSourceEngines(engineName: string): { engineName: string }; + hideRow(itemId: string): { itemId: string }; +} + +interface EnginesAPIResponse { + results: EngineDetails[]; + meta: Meta; +} + +export const MetaEnginesTableLogic = kea< + MakeLogicType +>({ + path: ['enterprise_search', 'app_search', 'meta_engines_table_logic'], + actions: () => ({ + addSourceEngines: (sourceEngines) => ({ sourceEngines }), + displayRow: (itemId) => ({ itemId }), + hideRow: (itemId) => ({ itemId }), + fetchOrDisplayRow: (itemId) => ({ itemId }), + fetchSourceEngines: (engineName) => ({ engineName }), + }), + reducers: () => ({ + expandedRows: [ + {}, + { + displayRow: (expandedRows, { itemId }) => ({ + ...expandedRows, + [itemId]: true, + }), + hideRow: (expandedRows, { itemId }) => { + const newRows = { ...expandedRows }; + delete newRows[itemId]; + return newRows; + }, + }, + ], + sourceEngines: [ + {}, + { + addSourceEngines: (currentSourceEngines, { sourceEngines: newSourceEngines }) => ({ + ...currentSourceEngines, + ...newSourceEngines, + }), + }, + ], + }), + selectors: { + expandedSourceEngines: [ + (selectors) => [selectors.sourceEngines, selectors.expandedRows], + (sourceEngines: MetaEnginesTableValues['sourceEngines'], expandedRows: string[]) => { + return Object.keys(expandedRows).reduce((expandedRowMap, engineName) => { + expandedRowMap[engineName] = sourceEngines[engineName]; + return expandedRowMap; + }, {} as MetaEnginesTableValues['sourceEngines']); + }, + ], + }, + listeners: ({ actions, values }) => ({ + fetchOrDisplayRow: ({ itemId }) => { + const sourceEngines = values.sourceEngines; + if (sourceEngines[itemId]) { + actions.displayRow(itemId); + } else { + actions.fetchSourceEngines(itemId); + } + }, + fetchSourceEngines: ({ engineName }) => { + const { http } = HttpLogic.values; + + let enginesAccumulator: EngineDetails[] = []; + + const recursiveFetchSourceEngines = async (page = 1) => { + try { + const { meta, results }: EnginesAPIResponse = await http.get( + `/api/app_search/engines/${engineName}/source_engines`, + { + query: { + 'page[current]': page, + 'page[size]': 25, + }, + } + ); + + enginesAccumulator = [...enginesAccumulator, ...results]; + + if (page >= meta.page.total_pages) { + actions.addSourceEngines({ [engineName]: enginesAccumulator }); + actions.displayRow(engineName); + } else { + recursiveFetchSourceEngines(page + 1); + } + } catch (e) { + flashAPIErrors(e); + } + }; + + recursiveFetchSourceEngines(); + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_name_column_content.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_name_column_content.test.tsx new file mode 100644 index 00000000000000..df65f2f86e1749 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_name_column_content.test.tsx @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiHealth } from '@elastic/eui'; + +import { SchemaConflictFieldTypes, SchemaConflicts } from '../../../../../shared/types'; +import { EngineDetails } from '../../../engine/types'; + +import { MetaEnginesTableNameColumnContent } from './meta_engines_table_name_column_content'; + +describe('MetaEnginesTableNameColumnContent', () => { + it('includes the name of the engine', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find('[data-test-subj="EngineName"]')).toHaveLength(1); + }); + + describe('toggle button', () => { + it('displays expanded row when the row is currently hidden', () => { + const showRow = jest.fn(); + + const wrapper = shallow( + + ); + wrapper.find('[data-test-subj="ExpandRowButton"]').at(0).simulate('click'); + + expect(showRow).toHaveBeenCalled(); + }); + + it('hides expanded row when the row is currently visible', () => { + const hideRow = jest.fn(); + + const wrapper = shallow( + + ); + wrapper.find('[data-test-subj="ExpandRowButton"]').at(0).simulate('click'); + + expect(hideRow).toHaveBeenCalled(); + }); + }); + + describe('engine count', () => { + it('is included and labelled', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find('[data-test-subj="SourceEnginesCount"]')).toHaveLength(1); + }); + }); + + it('indicates the precense of field-type conflicts', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(EuiHealth)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_name_column_content.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_name_column_content.tsx new file mode 100644 index 00000000000000..e05246ab4d92cf --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/meta_engines_table_name_column_content.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiFlexGroup, EuiIcon, EuiHealth, EuiFlexItem } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { EngineDetails } from '../../../engine/types'; + +import { renderEngineLink } from './engine_link_helpers'; + +interface MetaEnginesTableNameContentProps { + isExpanded: boolean; + item: EngineDetails; + hideRow: (name: string) => void; + showRow: (name: string) => void; +} + +export const MetaEnginesTableNameColumnContent: React.FC = ({ + item: { name, schemaConflicts, engine_count: engineCount }, + isExpanded, + hideRow, + showRow, +}) => ( + + {renderEngineLink(name)} + + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/shared_columns.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/shared_columns.tsx new file mode 100644 index 00000000000000..3375b25cdcd6ca --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/shared_columns.tsx @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { + EuiTableFieldDataColumnType, + EuiTableComputedColumnType, + EuiTableActionsColumnType, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedNumber } from '@kbn/i18n/react'; + +import { MANAGE_BUTTON_LABEL, DELETE_BUTTON_LABEL } from '../../../../../shared/constants'; +import { FormattedDateTime } from '../../../../utils/formatted_date_time'; +import { EngineDetails } from '../../../engine/types'; +import { EnginesLogic } from '../../../engines'; + +import { navigateToEngine } from './engine_link_helpers'; + +export const BLANK_COLUMN: EuiTableComputedColumnType = { + render: () => <>, + 'aria-hidden': true, +}; + +export const NAME_COLUMN: EuiTableFieldDataColumnType = { + field: 'name', + name: i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.table.column.name', { + defaultMessage: 'Name', + }), + width: '30%', + truncateText: true, + mobileOptions: { + header: true, + // Note: the below props are valid props per https://elastic.github.io/eui/#/tabular-content/tables (Responsive tables), but EUI's types have a bug reporting it as an error + // @ts-ignore + enlarge: true, + width: '100%', + truncateText: false, + }, +}; + +export const CREATED_AT_COLUMN: EuiTableFieldDataColumnType = { + field: 'created_at', + name: i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.table.column.createdAt', { + defaultMessage: 'Created at', + }), + dataType: 'string', + render: (dateString: string) => , +}; + +export const DOCUMENT_COUNT_COLUMN: EuiTableFieldDataColumnType = { + field: 'document_count', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.documentCount', + { + defaultMessage: 'Document count', + } + ), + dataType: 'number', + render: (number: number) => , + truncateText: true, +}; + +export const FIELD_COUNT_COLUMN: EuiTableFieldDataColumnType = { + field: 'field_count', + name: i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.table.column.fieldCount', { + defaultMessage: 'Field count', + }), + dataType: 'number', + render: (number: number) => , + truncateText: true, +}; + +export const ACTIONS_COLUMN: EuiTableActionsColumnType = { + name: i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.table.column.actions', { + defaultMessage: 'Actions', + }), + actions: [ + { + name: MANAGE_BUTTON_LABEL, + description: i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.action.manage.buttonDescription', + { + defaultMessage: 'Manage this engine', + } + ), + type: 'icon', + icon: 'eye', + onClick: (engineDetails) => navigateToEngine(engineDetails.name), + }, + { + name: DELETE_BUTTON_LABEL, + description: i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.action.delete.buttonDescription', + { + defaultMessage: 'Delete this engine', + } + ), + type: 'icon', + icon: 'trash', + color: 'danger', + onClick: (engine) => { + if ( + window.confirm( + i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.action.delete.confirmationPopupMessage', + { + defaultMessage: + 'Are you sure you want to permanently delete "{engineName}" and all of its content?', + values: { + engineName: engine.name, + }, + } + ) + ) + ) { + EnginesLogic.actions.deleteEngine(engine); + } + }, + }, + ], +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/test_helpers/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/test_helpers/index.ts new file mode 100644 index 00000000000000..c2989c5d1f9725 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/test_helpers/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { runSharedColumnsTests } from './shared_columns'; +export { runSharedPropsTests } from './shared_props'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/test_helpers/shared_columns.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/test_helpers/shared_columns.tsx new file mode 100644 index 00000000000000..97e2057cea2d96 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/test_helpers/shared_columns.tsx @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockValues, rerender } from '../../../../../../__mocks__'; +import '../__mocks__/engines_logic.mock'; + +import { ShallowWrapper } from 'enzyme'; + +import { EuiBasicTable, EuiButtonIcon } from '@elastic/eui'; + +import { EnginesLogic } from '../../../../engines'; + +import * as engineLinkHelpers from '../engine_link_helpers'; + +export const runSharedColumnsTests = ( + wrapper: ShallowWrapper, + tableContent: string, + values: object = {} +) => { + const getTable = () => wrapper.find(EuiBasicTable).dive(); + + describe('name column', () => { + it('renders', () => { + expect(tableContent).toContain('test-engine'); + }); + + // Link behavior is tested in engine_link_helpers.test.tsx + }); + + describe('created at column', () => { + it('renders', () => { + expect(tableContent).toContain('Created at'); + expect(tableContent).toContain('Jan 1, 1970'); + }); + }); + + describe('document count column', () => { + it('renders', () => { + expect(tableContent).toContain('Document count'); + expect(tableContent).toContain('99,999'); + }); + }); + + describe('field count column', () => { + it('renders', () => { + expect(tableContent).toContain('Field count'); + expect(tableContent).toContain('10'); + }); + }); + + describe('actions column', () => { + const getActions = () => getTable().find('ExpandedItemActions'); + const getActionItems = () => getActions().dive().find('DefaultItemAction'); + + it('will hide the action buttons if the user cannot manage/delete engines', () => { + setMockValues({ + ...values, + myRole: { canManageEngines: false, canManageMetaEngines: false }, + }); + rerender(wrapper); + expect(getActions()).toHaveLength(0); + }); + + describe('when the user can manage/delete engines', () => { + const getManageAction = () => getActionItems().at(0).dive().find(EuiButtonIcon); + const getDeleteAction = () => getActionItems().at(1).dive().find(EuiButtonIcon); + + beforeAll(() => { + setMockValues({ + ...values, + myRole: { canManageEngines: true, canManageMetaEngines: true }, + }); + rerender(wrapper); + }); + + describe('manage action', () => { + it('sends the user to the engine overview on click', () => { + jest.spyOn(engineLinkHelpers, 'navigateToEngine'); + const { navigateToEngine } = engineLinkHelpers; + getManageAction().simulate('click'); + + expect(navigateToEngine).toHaveBeenCalledWith('test-engine'); + }); + }); + + describe('delete action', () => { + const { deleteEngine } = EnginesLogic.actions; + + it('clicking the action and confirming deletes the engine', () => { + jest.spyOn(global, 'confirm').mockReturnValueOnce(true); + getDeleteAction().simulate('click'); + + expect(deleteEngine).toHaveBeenCalledWith( + expect.objectContaining({ name: 'test-engine' }) + ); + }); + + it('clicking the action and not confirming does not delete the engine', () => { + jest.spyOn(global, 'confirm').mockReturnValueOnce(false); + getDeleteAction().simulate('click'); + + expect(deleteEngine).not.toHaveBeenCalled(); + }); + }); + }); + }); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/test_helpers/shared_props.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/test_helpers/shared_props.tsx new file mode 100644 index 00000000000000..0b0a8a0a995930 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/test_helpers/shared_props.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ShallowWrapper } from 'enzyme'; + +import { EuiBasicTable } from '@elastic/eui'; + +export const runSharedPropsTests = (wrapper: ShallowWrapper) => { + it('passes the loading prop', () => { + wrapper.setProps({ loading: true }); + expect(wrapper.find(EuiBasicTable).prop('loading')).toEqual(true); + }); + + it('passes the noItemsMessage prop', () => { + wrapper.setProps({ noItemsMessage: 'No items.' }); + expect(wrapper.find(EuiBasicTable).prop('noItemsMessage')).toEqual('No items.'); + }); + + describe('pagination', () => { + it('passes the pagination prop', () => { + const pagination = { + pageIndex: 0, + pageSize: 10, + totalItemCount: 50, + }; + wrapper.setProps({ pagination }); + expect(wrapper.find(EuiBasicTable).prop('pagination')).toEqual(pagination); + }); + + it('triggers onChange', () => { + const onChange = jest.fn(); + wrapper.setProps({ onChange }); + + wrapper.find(EuiBasicTable).simulate('change', { page: { index: 4 } }); + expect(onChange).toHaveBeenCalledWith({ page: { index: 4 } }); + }); + }); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/types.ts new file mode 100644 index 00000000000000..707c086e01827f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/types.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ReactNode } from 'react'; + +import { CriteriaWithPagination } from '@elastic/eui'; + +import { EngineDetails } from '../../../engine/types'; + +export interface EnginesTableProps { + items: EngineDetails[]; + loading: boolean; + noItemsMessage?: ReactNode; + pagination: { + pageIndex: number; + pageSize: number; + totalItemCount: number; + hidePerPageOptions: boolean; + }; + onChange(criteria: CriteriaWithPagination): void; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/utils.test.ts new file mode 100644 index 00000000000000..f65a2e52bae064 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/utils.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SchemaConflictFieldTypes, SchemaConflicts } from '../../../../../shared/types'; +import { EngineDetails } from '../../../engine/types'; + +import { + getConflictingEnginesFromConflictingField, + getConflictingEnginesFromSchemaConflicts, + getConflictingEnginesSet, +} from './utils'; + +describe('getConflictingEnginesFromConflictingField', () => { + const CONFLICTING_FIELD: SchemaConflictFieldTypes = { + text: ['source-engine-1'], + number: ['source-engine-2', 'source-engine-3'], + geolocation: ['source-engine-4'], + date: ['source-engine-5', 'source-engine-6'], + }; + + it('returns a flat array of all engines with conflicts across different schema types, including duplicates', () => { + const result = getConflictingEnginesFromConflictingField(CONFLICTING_FIELD); + + // we can't guarantee ordering + expect(result).toHaveLength(6); + expect(result).toContain('source-engine-1'); + expect(result).toContain('source-engine-2'); + expect(result).toContain('source-engine-3'); + expect(result).toContain('source-engine-4'); + expect(result).toContain('source-engine-5'); + expect(result).toContain('source-engine-6'); + }); +}); + +describe('getConflictingEnginesFromSchemaConflicts', () => { + it('returns a flat array of all engines with conflicts across all fields, including duplicates', () => { + const SCHEMA_CONFLICTS: SchemaConflicts = { + 'conflicting-field-1': { + text: ['source-engine-1'], + number: ['source-engine-2'], + geolocation: [], + date: [], + }, + 'conflicting-field-2': { + text: [], + number: [], + geolocation: ['source-engine-2'], + date: ['source-engine-3'], + }, + }; + + const result = getConflictingEnginesFromSchemaConflicts(SCHEMA_CONFLICTS); + + // we can't guarantee ordering + expect(result).toHaveLength(4); + expect(result).toContain('source-engine-1'); + expect(result).toContain('source-engine-2'); + expect(result).toContain('source-engine-3'); + }); +}); + +describe('getConflictingEnginesSet', () => { + const DEFAULT_META_ENGINE_DETAILS = { + name: 'test-engine-1', + includedEngines: [ + { + name: 'source-engine-1', + }, + { + name: 'source-engine-2', + }, + { + name: 'source-engine-3', + }, + ] as EngineDetails[], + schemaConflicts: { + 'conflicting-field-1': { + text: ['source-engine-1'], + number: ['source-engine-2'], + geolocation: [], + date: [], + }, + 'conflicting-field-2': { + text: [], + number: [], + geolocation: ['source-engine-2'], + date: ['source-engine-3'], + }, + } as SchemaConflicts, + } as EngineDetails; + + it('generates a set of engine names with any field conflicts for the meta-engine', () => { + expect(getConflictingEnginesSet(DEFAULT_META_ENGINE_DETAILS)).toEqual( + new Set(['source-engine-1', 'source-engine-2', 'source-engine-3']) + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/utils.ts new file mode 100644 index 00000000000000..b1172237e3ad30 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/utils.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SchemaConflictFieldTypes, SchemaConflicts } from '../../../../../shared/types'; +import { EngineDetails } from '../../../engine/types'; + +export const getConflictingEnginesFromConflictingField = ( + conflictingField: SchemaConflictFieldTypes +): string[] => Object.values(conflictingField).flat(); + +export const getConflictingEnginesFromSchemaConflicts = ( + schemaConflicts: SchemaConflicts +): string[] => Object.values(schemaConflicts).flatMap(getConflictingEnginesFromConflictingField); + +// Given a meta-engine (represented by IEngineDetails), generate a Set of all source engines +// who have schema conflicts in the context of that meta-engine +// +// A Set allows us to enforce uniqueness and has O(1) lookup time +export const getConflictingEnginesSet = (metaEngine: EngineDetails): Set => { + const conflictingEngines: string[] = metaEngine.schemaConflicts + ? getConflictingEnginesFromSchemaConflicts(metaEngine.schemaConflicts) + : []; + return new Set(conflictingEngines); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts index 1955084393e570..c6c077e984efe7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts @@ -16,6 +16,11 @@ export const META_ENGINES_TITLE = i18n.translate( { defaultMessage: 'Meta Engines' } ); +export const SOURCE_ENGINES_TITLE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.metaEnginesTable.sourceEngines.title', + { defaultMessage: 'Source Engines' } +); + export const CREATE_AN_ENGINE_BUTTON_LABEL = i18n.translate( 'xpack.enterpriseSearch.appSearch.engines.createAnEngineButton.ButtonLabel', { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx index 3ca039907932ee..c47b169ede3644 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx @@ -15,7 +15,8 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { EuiEmptyPrompt } from '@elastic/eui'; import { LoadingState, EmptyState } from './components'; -import { EnginesTable } from './engines_table'; +import { EnginesTable } from './components/tables/engines_table'; +import { MetaEnginesTable } from './components/tables/meta_engines_table'; import { EnginesOverview } from './'; @@ -41,7 +42,11 @@ describe('EnginesOverview', () => { }, metaEnginesLoading: false, hasPlatinumLicense: false, + // AppLogic myRole: { canManageEngines: false }, + // MetaEnginesTableLogic + expandedSourceEngines: {}, + conflictingEnginesSets: {}, }; const actions = { loadEngines: jest.fn(), @@ -120,7 +125,7 @@ describe('EnginesOverview', () => { }); const wrapper = shallow(); - expect(wrapper.find(EnginesTable)).toHaveLength(2); + expect(wrapper.find(MetaEnginesTable)).toHaveLength(1); expect(actions.loadMetaEngines).toHaveBeenCalled(); }); @@ -147,7 +152,7 @@ describe('EnginesOverview', () => { metaEngines: [], }); const wrapper = shallow(); - const metaEnginesTable = wrapper.find(EnginesTable).last().dive(); + const metaEnginesTable = wrapper.find(MetaEnginesTable).dive(); const emptyPrompt = metaEnginesTable.dive().find(EuiEmptyPrompt).dive(); expect( @@ -199,10 +204,10 @@ describe('EnginesOverview', () => { const wrapper = shallow(); const pageEvent = { page: { index: 0 } }; - wrapper.find(EnginesTable).first().simulate('change', pageEvent); + wrapper.find(EnginesTable).simulate('change', pageEvent); expect(actions.onEnginesPagination).toHaveBeenCalledWith(1); - wrapper.find(EnginesTable).last().simulate('change', pageEvent); + wrapper.find(MetaEnginesTable).simulate('change', pageEvent); expect(actions.onMetaEnginesPagination).toHaveBeenCalledWith(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx index d7e2309fd2a07e..4e17278d25d1a3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx @@ -29,6 +29,8 @@ import { EngineIcon, MetaEngineIcon } from '../../icons'; import { ENGINE_CREATION_PATH, META_ENGINE_CREATION_PATH } from '../../routes'; import { EnginesOverviewHeader, LoadingState, EmptyState } from './components'; +import { EnginesTable } from './components/tables/engines_table'; +import { MetaEnginesTable } from './components/tables/meta_engines_table'; import { CREATE_AN_ENGINE_BUTTON_LABEL, CREATE_A_META_ENGINE_BUTTON_LABEL, @@ -38,7 +40,6 @@ import { META_ENGINES_TITLE, } from './constants'; import { EnginesLogic } from './engines_logic'; -import { EnginesTable } from './engines_table'; import './engines_overview.scss'; @@ -58,13 +59,9 @@ export const EnginesOverview: React.FC = () => { metaEnginesLoading, } = useValues(EnginesLogic); - const { - deleteEngine, - loadEngines, - loadMetaEngines, - onEnginesPagination, - onMetaEnginesPagination, - } = useActions(EnginesLogic); + const { loadEngines, loadMetaEngines, onEnginesPagination, onMetaEnginesPagination } = useActions( + EnginesLogic + ); useEffect(() => { loadEngines(); @@ -116,7 +113,6 @@ export const EnginesOverview: React.FC = () => { hidePerPageOptions: true, }} onChange={handlePageChange(onEnginesPagination)} - onDeleteEngine={deleteEngine} /> @@ -146,7 +142,7 @@ export const EnginesOverview: React.FC = () => { - { /> } onChange={handlePageChange(onMetaEnginesPagination)} - onDeleteEngine={deleteEngine} /> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.test.tsx deleted file mode 100644 index fc37c3543af569..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.test.tsx +++ /dev/null @@ -1,245 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import '../../../__mocks__/enterprise_search_url.mock'; -import { mockTelemetryActions, mountWithIntl, setMockValues } from '../../../__mocks__'; - -import React from 'react'; - -import { ReactWrapper, shallow } from 'enzyme'; - -import { EuiBasicTable, EuiPagination, EuiButtonEmpty, EuiIcon, EuiTableRow } from '@elastic/eui'; - -import { KibanaLogic } from '../../../shared/kibana'; -import { EuiLinkTo } from '../../../shared/react_router_helpers'; - -import { TelemetryLogic } from '../../../shared/telemetry'; -import { EngineDetails } from '../engine/types'; - -import { EnginesLogic } from './engines_logic'; -import { EnginesTable } from './engines_table'; - -describe('EnginesTable', () => { - const onChange = jest.fn(); - const onDeleteEngine = jest.fn(); - - const data = [ - { - name: 'test-engine', - created_at: 'Fri, 1 Jan 1970 12:00:00 +0000', - language: 'English', - isMeta: false, - document_count: 99999, - field_count: 10, - } as EngineDetails, - ]; - const pagination = { - pageIndex: 0, - pageSize: 10, - totalItemCount: 50, - hidePerPageOptions: true, - }; - const props = { - items: data, - loading: false, - pagination, - onChange, - onDeleteEngine, - }; - - const resetMocks = () => { - jest.clearAllMocks(); - setMockValues({ - myRole: { - canManageEngines: false, - }, - }); - }; - - describe('basic table', () => { - let wrapper: ReactWrapper; - let table: ReactWrapper; - - beforeAll(() => { - resetMocks(); - wrapper = mountWithIntl(); - table = wrapper.find(EuiBasicTable); - }); - - it('renders', () => { - expect(table).toHaveLength(1); - expect(table.prop('pagination').totalItemCount).toEqual(50); - - const tableContent = table.text(); - expect(tableContent).toContain('test-engine'); - expect(tableContent).toContain('Jan 1, 1970'); - expect(tableContent).toContain('English'); - expect(tableContent).toContain('99,999'); - expect(tableContent).toContain('10'); - - expect(table.find(EuiPagination).find(EuiButtonEmpty)).toHaveLength(5); // Should display 5 pages at 10 engines per page - }); - - it('contains engine links which send telemetry', () => { - const engineLinks = wrapper.find(EuiLinkTo); - - engineLinks.forEach((link) => { - expect(link.prop('to')).toEqual('/engines/test-engine'); - link.simulate('click'); - - expect(mockTelemetryActions.sendAppSearchTelemetry).toHaveBeenCalledWith({ - action: 'clicked', - metric: 'engine_table_link', - }); - }); - }); - - it('triggers onPaginate', () => { - table.prop('onChange')({ page: { index: 4 } }); - expect(onChange).toHaveBeenCalledWith({ page: { index: 4 } }); - }); - }); - - describe('loading', () => { - it('passes the loading prop', () => { - resetMocks(); - const wrapper = mountWithIntl(); - - expect(wrapper.find(EuiBasicTable).prop('loading')).toEqual(true); - }); - }); - - describe('noItemsMessage', () => { - it('passes the noItemsMessage prop', () => { - resetMocks(); - const wrapper = mountWithIntl(); - expect(wrapper.find(EuiBasicTable).prop('noItemsMessage')).toEqual('No items.'); - }); - }); - - describe('language field', () => { - beforeAll(() => { - resetMocks(); - }); - - it('renders language when available', () => { - const wrapper = mountWithIntl( - - ); - const tableContent = wrapper.find(EuiBasicTable).text(); - expect(tableContent).toContain('German'); - }); - - it('renders the language as Universal if no language is set', () => { - const wrapper = mountWithIntl( - - ); - const tableContent = wrapper.find(EuiBasicTable).text(); - expect(tableContent).toContain('Universal'); - }); - - it('renders no language text if the engine is a Meta Engine', () => { - const wrapper = mountWithIntl( - - ); - const tableContent = wrapper.find(EuiBasicTable).text(); - expect(tableContent).not.toContain('Universal'); - }); - }); - - describe('actions', () => { - it('will hide the action buttons if the user cannot manage/delete engines', () => { - resetMocks(); - const wrapper = shallow(); - const tableRow = wrapper.find(EuiTableRow).first(); - - expect(tableRow.find(EuiIcon)).toHaveLength(0); - }); - - describe('when the user can manage/delete engines', () => { - let wrapper: ReactWrapper; - let tableRow: ReactWrapper; - let actions: ReactWrapper; - - beforeEach(() => { - resetMocks(); - setMockValues({ - myRole: { - canManageEngines: true, - }, - }); - - wrapper = mountWithIntl(); - tableRow = wrapper.find(EuiTableRow).first(); - actions = tableRow.find(EuiIcon); - EnginesLogic.mount(); - }); - - it('renders a manage action', () => { - jest.spyOn(TelemetryLogic.actions, 'sendAppSearchTelemetry'); - jest.spyOn(KibanaLogic.values, 'navigateToUrl'); - actions.at(0).simulate('click'); - - expect(TelemetryLogic.actions.sendAppSearchTelemetry).toHaveBeenCalled(); - expect(KibanaLogic.values.navigateToUrl).toHaveBeenCalledWith('/engines/test-engine'); - }); - - describe('delete action', () => { - it('shows the user a confirm message when the action is clicked', () => { - jest.spyOn(global, 'confirm' as any).mockReturnValueOnce(true); - actions.at(1).simulate('click'); - expect(global.confirm).toHaveBeenCalled(); - }); - - it('clicking the action and confirming deletes the engine', () => { - jest.spyOn(global, 'confirm' as any).mockReturnValueOnce(true); - jest.spyOn(EnginesLogic.actions, 'deleteEngine'); - - actions.at(1).simulate('click'); - - expect(onDeleteEngine).toHaveBeenCalled(); - }); - - it('clicking the action and not confirming does not delete the engine', () => { - jest.spyOn(global, 'confirm' as any).mockReturnValueOnce(false); - jest.spyOn(EnginesLogic.actions, 'deleteEngine'); - - actions.at(1).simulate('click'); - - expect(onDeleteEngine).toHaveBeenCalledTimes(0); - }); - }); - }); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.tsx deleted file mode 100644 index 3a65d9c449d6ec..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.tsx +++ /dev/null @@ -1,210 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { ReactNode } from 'react'; - -import { useActions, useValues } from 'kea'; - -import { - EuiBasicTable, - EuiBasicTableColumn, - CriteriaWithPagination, - EuiTableActionsColumnType, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedNumber } from '@kbn/i18n/react'; - -import { MANAGE_BUTTON_LABEL, DELETE_BUTTON_LABEL } from '../../../shared/constants'; -import { KibanaLogic } from '../../../shared/kibana'; -import { EuiLinkTo } from '../../../shared/react_router_helpers'; -import { TelemetryLogic } from '../../../shared/telemetry'; -import { AppLogic } from '../../app_logic'; -import { UNIVERSAL_LANGUAGE } from '../../constants'; -import { ENGINE_PATH } from '../../routes'; -import { generateEncodedPath } from '../../utils/encode_path_params'; -import { FormattedDateTime } from '../../utils/formatted_date_time'; -import { EngineDetails } from '../engine/types'; - -interface EnginesTableProps { - items: EngineDetails[]; - loading: boolean; - noItemsMessage?: ReactNode; - pagination: { - pageIndex: number; - pageSize: number; - totalItemCount: number; - hidePerPageOptions: boolean; - }; - onChange(criteria: CriteriaWithPagination): void; - onDeleteEngine(engine: EngineDetails): void; -} - -export const EnginesTable: React.FC = ({ - items, - loading, - noItemsMessage, - pagination, - onChange, - onDeleteEngine, -}) => { - const { sendAppSearchTelemetry } = useActions(TelemetryLogic); - const { navigateToUrl } = useValues(KibanaLogic); - const { - myRole: { canManageEngines }, - } = useValues(AppLogic); - - const generateEncodedEnginePath = (engineName: string) => - generateEncodedPath(ENGINE_PATH, { engineName }); - const sendEngineTableLinkClickTelemetry = () => - sendAppSearchTelemetry({ - action: 'clicked', - metric: 'engine_table_link', - }); - - const columns: Array> = [ - { - field: 'name', - name: i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.table.column.name', { - defaultMessage: 'Name', - }), - render: (name: string) => ( - - {name} - - ), - width: '30%', - truncateText: true, - mobileOptions: { - header: true, - // Note: the below props are valid props per https://elastic.github.io/eui/#/tabular-content/tables (Responsive tables), but EUI's types have a bug reporting it as an error - // @ts-ignore - enlarge: true, - fullWidth: true, - truncateText: false, - }, - }, - { - field: 'created_at', - name: i18n.translate( - 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.createdAt', - { - defaultMessage: 'Created At', - } - ), - dataType: 'string', - render: (dateString: string) => , - }, - { - field: 'language', - name: i18n.translate( - 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.language', - { - defaultMessage: 'Language', - } - ), - dataType: 'string', - render: (language: string, engine: EngineDetails) => - engine.isMeta ? '' : language || UNIVERSAL_LANGUAGE, - }, - { - field: 'document_count', - name: i18n.translate( - 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.documentCount', - { - defaultMessage: 'Document Count', - } - ), - dataType: 'number', - render: (number: number) => , - truncateText: true, - }, - { - field: 'field_count', - name: i18n.translate( - 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.fieldCount', - { - defaultMessage: 'Field Count', - } - ), - dataType: 'number', - render: (number: number) => , - truncateText: true, - }, - ]; - - const actionsColumn: EuiTableActionsColumnType = { - name: i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.table.column.actions', { - defaultMessage: 'Actions', - }), - actions: [ - { - name: MANAGE_BUTTON_LABEL, - description: i18n.translate( - 'xpack.enterpriseSearch.appSearch.enginesOverview.table.action.manage.buttonDescription', - { - defaultMessage: 'Manage this engine', - } - ), - type: 'icon', - icon: 'eye', - onClick: (engineDetails) => { - sendEngineTableLinkClickTelemetry(); - navigateToUrl(generateEncodedEnginePath(engineDetails.name)); - }, - }, - { - name: DELETE_BUTTON_LABEL, - description: i18n.translate( - 'xpack.enterpriseSearch.appSearch.enginesOverview.table.action.delete.buttonDescription', - { - defaultMessage: 'Delete this engine', - } - ), - type: 'icon', - icon: 'trash', - color: 'danger', - onClick: (engine) => { - if ( - window.confirm( - i18n.translate( - 'xpack.enterpriseSearch.appSearch.enginesOverview.table.action.delete.confirmationPopupMessage', - { - defaultMessage: - 'Are you sure you want to permanently delete "{engineName}" and all of its content?', - values: { - engineName: engine.name, - }, - } - ) - ) - ) { - onDeleteEngine(engine); - } - }, - }, - ], - }; - - if (canManageEngines) { - columns.push(actionsColumn); - } - - return ( - - ); -}; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts index c653cad5c1c0d7..bc4259fa37889e 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts @@ -259,4 +259,47 @@ describe('engine routes', () => { }); }); }); + + describe('GET /api/app_search/engines/{name}/source_engines', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/engines/{name}/source_engines', + }); + + registerEnginesRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('validates correctly with name', () => { + const request = { params: { name: 'test-engine' } }; + mockRouter.shouldValidate(request); + }); + + it('fails validation without name', () => { + const request = { params: {} }; + mockRouter.shouldThrow(request); + }); + + it('fails validation with a non-string name', () => { + const request = { params: { name: 1 } }; + mockRouter.shouldThrow(request); + }); + + it('fails validation with missing query params', () => { + const request = { query: {} }; + mockRouter.shouldThrow(request); + }); + + it('creates a request to enterprise search', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/:name/source_engines', + }); + }); + }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts index 77b055add7d793..f6e9d30dd0adeb 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts @@ -95,4 +95,21 @@ export function registerEnginesRoutes({ path: '/as/engines/:name/overview_metrics', }) ); + router.get( + { + path: '/api/app_search/engines/{name}/source_engines', + validate: { + params: schema.object({ + name: schema.string(), + }), + query: schema.object({ + 'page[current]': schema.number(), + 'page[size]': schema.number(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/as/engines/:name/source_engines', + }) + ); } From 2c73115b74a0c1e38e460e8aaff6f26628d25419 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Tue, 13 Apr 2021 21:46:05 -0400 Subject: [PATCH 57/90] [ML] Data Frame Analytics: remove beta badge (#96977) * remove beta badge from DFA jobs list * remove unused translations --- .../pages/analytics_management/page.tsx | 14 -------------- .../plugins/translations/translations/ja-JP.json | 2 -- .../plugins/translations/translations/zh-CN.json | 2 -- 3 files changed, 18 deletions(-) diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx index b9af6750d6ee9d..f32e60dcf3cc1d 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx @@ -8,10 +8,8 @@ import React, { FC, Fragment, useMemo, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; import { - EuiBetaBadge, EuiFlexGroup, EuiFlexItem, EuiPage, @@ -81,18 +79,6 @@ export const Page: FC = () => { id="xpack.ml.dataframe.analyticsList.title" defaultMessage="Data frame analytics" /> -   -
diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 014d3d943d9b8f..4ec86a71dcb2aa 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -13494,8 +13494,6 @@ "xpack.ml.dataframe.analytics.rocChartSpec.yAxisTitle": "検出率 (TRP) (Recall) ", "xpack.ml.dataframe.analyticsList.analyticsDetails.tabs.analyticsMessagesLabel": "ジョブメッセージ", "xpack.ml.dataframe.analyticsList.analyticsDetails.tabs.analyticsStatsLabel": "ジョブ統計情報", - "xpack.ml.dataframe.analyticsList.betaBadgeLabel": "ベータ", - "xpack.ml.dataframe.analyticsList.betaBadgeTooltipContent": "データフレーム分析はベータ機能です。フィードバックをお待ちしています。", "xpack.ml.dataframe.analyticsList.cloneActionNameText": "クローンを作成", "xpack.ml.dataframe.analyticsList.cloneActionPermissionTooltip": "分析ジョブを複製する権限がありません。", "xpack.ml.dataframe.analyticsList.completeBatchAnalyticsToolTip": "{analyticsId}は完了済みの分析ジョブで、再度開始できません。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 77324bdddf4792..97317818f10cb9 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -13669,8 +13669,6 @@ "xpack.ml.dataframe.analytics.rocChartSpec.yAxisTitle": "真正类率 (TPR) (也称为查全率) ", "xpack.ml.dataframe.analyticsList.analyticsDetails.tabs.analyticsMessagesLabel": "作业消息", "xpack.ml.dataframe.analyticsList.analyticsDetails.tabs.analyticsStatsLabel": "作业统计信息", - "xpack.ml.dataframe.analyticsList.betaBadgeLabel": "公测版", - "xpack.ml.dataframe.analyticsList.betaBadgeTooltipContent": "数据帧分析是公测版功能。我们很乐意听取您的反馈意见。", "xpack.ml.dataframe.analyticsList.cloneActionNameText": "克隆", "xpack.ml.dataframe.analyticsList.cloneActionPermissionTooltip": "您无权克隆分析作业。", "xpack.ml.dataframe.analyticsList.completeBatchAnalyticsToolTip": "{analyticsId} 为已完成的分析作业,无法重新启动。", From 39e4ea8f44f59e9784716509d14f2e06d389a3b6 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Tue, 13 Apr 2021 20:52:17 -0700 Subject: [PATCH 58/90] [Fleet] Improve performance of data stream API (#97058) * Improve performance of data stream API * Remove extra logger, replace filter with reduce * Remove unused import --- .../server/routes/data_streams/handlers.ts | 82 ++++++++++++------- .../fleet/server/services/epm/packages/get.ts | 9 -- 2 files changed, 51 insertions(+), 40 deletions(-) diff --git a/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts b/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts index c684c050036124..6d4d107adb796d 100644 --- a/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts @@ -6,12 +6,12 @@ */ import { keyBy, keys, merge } from 'lodash'; -import type { RequestHandler, SavedObjectsClientContract } from 'src/core/server'; +import type { RequestHandler, SavedObjectsBulkGetObject } from 'src/core/server'; import type { DataStream } from '../../types'; -import { KibanaAssetType, KibanaSavedObjectType } from '../../../common'; +import { KibanaSavedObjectType } from '../../../common'; import type { GetDataStreamsResponse } from '../../../common'; -import { getPackageSavedObjects, getKibanaSavedObject } from '../../services/epm/packages/get'; +import { getPackageSavedObjects } from '../../services/epm/packages/get'; import { defaultIngestErrorHandler } from '../../errors'; const DATA_STREAM_INDEX_PATTERN = 'logs-*-*,metrics-*-*,traces-*-*'; @@ -78,6 +78,40 @@ export const getListHandler: RequestHandler = async (context, request, response) const packageSavedObjectsByName = keyBy(packageSavedObjects.saved_objects, 'id'); const packageMetadata: any = {}; + // Get dashboard information for all packages + const dashboardIdsByPackageName = packageSavedObjects.saved_objects.reduce< + Record + >((allDashboards, pkgSavedObject) => { + const dashboards: string[] = []; + (pkgSavedObject.attributes?.installed_kibana || []).forEach((o) => { + if (o.type === KibanaSavedObjectType.dashboard) { + dashboards.push(o.id); + } + }); + allDashboards[pkgSavedObject.id] = dashboards; + return allDashboards; + }, {}); + const allDashboardSavedObjects = await context.core.savedObjects.client.bulkGet<{ + title?: string; + }>( + Object.values(dashboardIdsByPackageName).reduce( + (allDashboards, dashboardIds) => { + return allDashboards.concat( + dashboardIds.map((id) => ({ + id, + type: KibanaSavedObjectType.dashboard, + fields: ['title'], + })) + ); + }, + [] + ) + ); + const allDashboardSavedObjectsById = keyBy( + allDashboardSavedObjects.saved_objects, + (dashboardSavedObject) => dashboardSavedObject.id + ); + // Query additional information for each data stream const dataStreamPromises = dataStreamNames.map(async (dataStreamName) => { const dataStream = dataStreams[dataStreamName]; @@ -158,19 +192,23 @@ export const getListHandler: RequestHandler = async (context, request, response) // - and we didn't pick the metadata in an earlier iteration of this map() if (!packageMetadata[pkgName]) { // then pick the dashboards from the package saved object - const dashboards = - pkgSavedObject.attributes?.installed_kibana?.filter( - (o) => o.type === KibanaSavedObjectType.dashboard - ) || []; - // and then pick the human-readable titles from the dashboard saved objects - const enhancedDashboards = await getEnhancedDashboards( - context.core.savedObjects.client, - dashboards - ); + const packageDashboardIds = dashboardIdsByPackageName[pkgName] || []; + const packageDashboards = packageDashboardIds.reduce< + Array<{ id: string; title: string }> + >((dashboards, dashboardId) => { + const dashboard = allDashboardSavedObjectsById[dashboardId]; + if (dashboard) { + dashboards.push({ + id: dashboard.id, + title: dashboard.attributes.title || dashboard.id, + }); + } + return dashboards; + }, []); packageMetadata[pkgName] = { version: pkgSavedObject.attributes?.version || '', - dashboards: enhancedDashboards, + dashboards: packageDashboards, }; } @@ -195,21 +233,3 @@ export const getListHandler: RequestHandler = async (context, request, response) return defaultIngestErrorHandler({ error, response }); } }; - -const getEnhancedDashboards = async ( - savedObjectsClient: SavedObjectsClientContract, - dashboards: any[] -) => { - const dashboardsPromises = dashboards.map(async (db) => { - const dbSavedObject: any = await getKibanaSavedObject( - savedObjectsClient, - KibanaAssetType.dashboard, - db.id - ); - return { - id: db.id, - title: dbSavedObject.attributes?.title || db.id, - }; - }); - return await Promise.all(dashboardsPromises); -}; diff --git a/x-pack/plugins/fleet/server/services/epm/packages/get.ts b/x-pack/plugins/fleet/server/services/epm/packages/get.ts index 98dbd3bd571621..706b2679ed2eb9 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/get.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/get.ts @@ -19,7 +19,6 @@ import type { RegistryPackage, EpmPackageAdditions, } from '../../../../common/types'; -import type { KibanaAssetType } from '../../../types'; import type { Installation, PackageInfo } from '../../../types'; import { IngestManagerError } from '../../../errors'; import { appContextService } from '../../'; @@ -260,11 +259,3 @@ function sortByName(a: { name: string }, b: { name: string }) { return 0; } } - -export async function getKibanaSavedObject( - savedObjectsClient: SavedObjectsClientContract, - type: KibanaAssetType, - id: string -) { - return savedObjectsClient.get(type, id); -} From 8db70bca19e8c6227d61de9da3ce450521ba6643 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Wed, 14 Apr 2021 08:33:27 +0300 Subject: [PATCH 59/90] Unskip heatmap suite and fixes flakiness (#96941) --- test/functional/apps/visualize/_heatmap_chart.ts | 3 +-- test/functional/apps/visualize/index.ts | 5 ----- test/functional/page_objects/visualize_editor_page.ts | 4 +--- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/test/functional/apps/visualize/_heatmap_chart.ts b/test/functional/apps/visualize/_heatmap_chart.ts index 79a9a6cbd5acae..660f45179631ed 100644 --- a/test/functional/apps/visualize/_heatmap_chart.ts +++ b/test/functional/apps/visualize/_heatmap_chart.ts @@ -15,8 +15,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const inspector = getService('inspector'); const PageObjects = getPageObjects(['visualize', 'visEditor', 'visChart', 'timePicker']); - // FLAKY: https://github.com/elastic/kibana/issues/95642 - describe.skip('heatmap chart', function indexPatternCreation() { + describe('heatmap chart', function indexPatternCreation() { const vizName1 = 'Visualization HeatmapChart'; before(async function () { diff --git a/test/functional/apps/visualize/index.ts b/test/functional/apps/visualize/index.ts index 0a3632e4aaa814..747494a690c7ed 100644 --- a/test/functional/apps/visualize/index.ts +++ b/test/functional/apps/visualize/index.ts @@ -56,11 +56,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_point_series_options')); loadTestFile(require.resolve('./_vertical_bar_chart')); loadTestFile(require.resolve('./_vertical_bar_chart_nontimeindex')); - - // Test non-replaced vislib chart types - loadTestFile(require.resolve('./_gauge_chart')); - loadTestFile(require.resolve('./_heatmap_chart')); - loadTestFile(require.resolve('./_pie_chart')); }); describe('', function () { diff --git a/test/functional/page_objects/visualize_editor_page.ts b/test/functional/page_objects/visualize_editor_page.ts index 5f05d825dd0f45..97627556abc630 100644 --- a/test/functional/page_objects/visualize_editor_page.ts +++ b/test/functional/page_objects/visualize_editor_page.ts @@ -128,9 +128,7 @@ export function VisualizeEditorPageProvider({ getService, getPageObjects }: FtrP } public async changeHeatmapColorNumbers(value = 6) { - const input = await testSubjects.find(`heatmapColorsNumber`); - await input.clearValueWithKeyboard(); - await input.type(`${value}`); + await testSubjects.setValue('heatmapColorsNumber', `${value}`); } public async getBucketErrorMessage() { From f0b1b903d554942f6c2d8c954760b846723ffab7 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Wed, 14 Apr 2021 08:34:50 +0300 Subject: [PATCH 60/90] [Datatable] Fix filter cell flakiness (#96934) --- test/functional/apps/visualize/_data_table.ts | 18 ++++++++---------- .../page_objects/visualize_chart_page.ts | 5 +++-- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/test/functional/apps/visualize/_data_table.ts b/test/functional/apps/visualize/_data_table.ts index 96cbf97621b089..1ff5bdcc6da78f 100644 --- a/test/functional/apps/visualize/_data_table.ts +++ b/test/functional/apps/visualize/_data_table.ts @@ -267,16 +267,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should apply correct filter', async () => { - await retry.try(async () => { - await PageObjects.visChart.filterOnTableCell(1, 3); - await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - const data = await PageObjects.visChart.getTableVisContent(); - expect(data).to.be.eql([ - ['png', '1,373'], - ['gif', '918'], - ['Other', '445'], - ]); - }); + await PageObjects.visChart.filterOnTableCell(1, 3); + await PageObjects.visChart.waitForVisualizationRenderingStabilized(); + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([ + ['png', '1,373'], + ['gif', '918'], + ['Other', '445'], + ]); }); }); diff --git a/test/functional/page_objects/visualize_chart_page.ts b/test/functional/page_objects/visualize_chart_page.ts index cd1c5cf318e63a..7b69101b92475c 100644 --- a/test/functional/page_objects/visualize_chart_page.ts +++ b/test/functional/page_objects/visualize_chart_page.ts @@ -419,12 +419,13 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr public async filterOnTableCell(columnIndex: number, rowIndex: number) { await retry.try(async () => { const cell = await dataGrid.getCellElement(rowIndex, columnIndex); - await cell.focus(); + await cell.click(); const filterBtn = await testSubjects.findDescendant( 'tbvChartCell__filterForCellValue', cell ); - await filterBtn.click(); + await common.sleep(2000); + filterBtn.click(); }); } From b0772471ce74b3656d8bdbf9e4ab4d2290fd3017 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Wed, 14 Apr 2021 03:52:12 -0400 Subject: [PATCH 61/90] [TSVB] Fix per-request caching of index patterns (#97043) --- .../common/__mocks__/index_patterns_utils.ts | 18 ++++++++++++ .../lib/cached_index_pattern_fetcher.test.ts | 28 +++++++++++++++++++ .../lib/cached_index_pattern_fetcher.ts | 2 +- 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 src/plugins/vis_type_timeseries/common/__mocks__/index_patterns_utils.ts diff --git a/src/plugins/vis_type_timeseries/common/__mocks__/index_patterns_utils.ts b/src/plugins/vis_type_timeseries/common/__mocks__/index_patterns_utils.ts new file mode 100644 index 00000000000000..9e41df38804195 --- /dev/null +++ b/src/plugins/vis_type_timeseries/common/__mocks__/index_patterns_utils.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +const mock = jest.requireActual('../index_patterns_utils'); + +jest.spyOn(mock, 'fetchIndexPattern'); + +export const { + isStringTypeIndexPattern, + getIndexPatternKey, + extractIndexPatternValues, + fetchIndexPattern, +} = mock; diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts index 3e6f8c2962d5a0..813b0a22c0c371 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts @@ -7,11 +7,14 @@ */ import { IndexPattern, IndexPatternsService } from 'src/plugins/data/server'; +import { fetchIndexPattern } from '../../../../common/index_patterns_utils'; import { getCachedIndexPatternFetcher, CachedIndexPatternFetcher, } from './cached_index_pattern_fetcher'; +jest.mock('../../../../common/index_patterns_utils'); + describe('CachedIndexPatternFetcher', () => { let mockedIndices: IndexPattern[] | []; let cachedIndexPatternFetcher: CachedIndexPatternFetcher; @@ -25,6 +28,8 @@ describe('CachedIndexPatternFetcher', () => { find: jest.fn(() => Promise.resolve(mockedIndices || [])), } as unknown) as IndexPatternsService; + (fetchIndexPattern as jest.Mock).mockClear(); + cachedIndexPatternFetcher = getCachedIndexPatternFetcher(indexPatternsService); }); @@ -52,6 +57,14 @@ describe('CachedIndexPatternFetcher', () => { } `); }); + + test('should cache once', async () => { + await cachedIndexPatternFetcher('indexTitle'); + await cachedIndexPatternFetcher('indexTitle'); + await cachedIndexPatternFetcher('indexTitle'); + + expect(fetchIndexPattern as jest.Mock).toHaveBeenCalledTimes(1); + }); }); describe('object-based index', () => { @@ -86,5 +99,20 @@ describe('CachedIndexPatternFetcher', () => { } `); }); + + test('should cache once', async () => { + mockedIndices = [ + { + id: 'indexId', + title: 'indexTitle', + }, + ] as IndexPattern[]; + + await cachedIndexPatternFetcher({ id: 'indexId' }); + await cachedIndexPatternFetcher({ id: 'indexId' }); + await cachedIndexPatternFetcher({ id: 'indexId' }); + + expect(fetchIndexPattern as jest.Mock).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts index 68cbd93cdc614d..b03fa973e9da99 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts @@ -23,7 +23,7 @@ export const getCachedIndexPatternFetcher = (indexPatternsService: IndexPatterns const fetchedIndex = fetchIndexPattern(indexPatternValue, indexPatternsService); - cache.set(indexPatternValue, fetchedIndex); + cache.set(key, fetchedIndex); return fetchedIndex; }; From 3a7f23efacfdc22f507a4a39118b117c2b38bbd4 Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm Date: Wed, 14 Apr 2021 11:00:06 +0200 Subject: [PATCH 62/90] [Discover][DocViewer] Fix toggle columns from doc viewer table tab (#95748) --- .../doc_viewer/doc_viewer_tab.test.tsx | 43 +++++++++++++++++++ .../components/doc_viewer/doc_viewer_tab.tsx | 2 + 2 files changed, 45 insertions(+) create mode 100644 src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.test.tsx diff --git a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.test.tsx b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.test.tsx new file mode 100644 index 00000000000000..a2434170acdd7d --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { DocViewerTab } from './doc_viewer_tab'; +import { ElasticSearchHit } from '../../doc_views/doc_views_types'; + +describe('DocViewerTab', () => { + test('changing columns triggers an update', () => { + const props = { + title: 'test', + component: jest.fn(), + id: 1, + render: jest.fn(), + renderProps: { + hit: {} as ElasticSearchHit, + columns: ['test'], + }, + }; + + const wrapper = shallow(); + + const nextProps = { + ...props, + renderProps: { + hit: {} as ElasticSearchHit, + columns: ['test2'], + }, + }; + + const shouldUpdate = (wrapper!.instance() as DocViewerTab).shouldComponentUpdate(nextProps, { + hasError: false, + error: '', + }); + expect(shouldUpdate).toBe(true); + }); +}); diff --git a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.tsx b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.tsx index 25454a3bad38ab..1ad6500771d483 100644 --- a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.tsx +++ b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.tsx @@ -7,6 +7,7 @@ */ import React from 'react'; +import { isEqual } from 'lodash'; import { I18nProvider } from '@kbn/i18n/react'; import { DocViewRenderTab } from './doc_viewer_render_tab'; import { DocViewerError } from './doc_viewer_render_error'; @@ -46,6 +47,7 @@ export class DocViewerTab extends React.Component { return ( nextProps.renderProps.hit._id !== this.props.renderProps.hit._id || nextProps.id !== this.props.id || + !isEqual(nextProps.renderProps.columns, this.props.renderProps.columns) || nextState.hasError ); } From 8c8fcf16c49a27a13f9a7e1020bf2dddccce1807 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Wed, 14 Apr 2021 11:46:12 +0200 Subject: [PATCH 63/90] added missing optional chain for bracket notation (#96939) --- .../rollup/server/routes/api/jobs/register_delete_route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/rollup/server/routes/api/jobs/register_delete_route.ts b/x-pack/plugins/rollup/server/routes/api/jobs/register_delete_route.ts index f90a81f73823ed..7e22b5c4ead10a 100644 --- a/x-pack/plugins/rollup/server/routes/api/jobs/register_delete_route.ts +++ b/x-pack/plugins/rollup/server/routes/api/jobs/register_delete_route.ts @@ -37,7 +37,7 @@ export const registerDeleteRoute = ({ // Until then we'll modify the response here. if ( err?.meta && - err.body?.task_failures[0]?.reason?.reason?.includes( + err.body?.task_failures?.[0]?.reason?.reason?.includes( 'Job must be [STOPPED] before deletion' ) ) { From f4f49bc32e22589030c9e3b9a7b03d33728151da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Wed, 14 Apr 2021 12:32:40 +0200 Subject: [PATCH 64/90] [Data telemetry] Add Async Search to the tests (#96693) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../get_data_telemetry/get_data_telemetry.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.test.ts b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.test.ts index c892f27905e0d6..d2113dce9548ff 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.test.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/get_data_telemetry.test.ts @@ -46,6 +46,15 @@ describe('get_data_telemetry', () => { ).toStrictEqual([]); }); + test('should not include Async Search indices', () => { + expect( + buildDataTelemetryPayload([ + { name: '.async_search', docCount: 0 }, + { name: '.async-search', docCount: 0 }, + ]) + ).toStrictEqual([]); + }); + test('matches some indices and puts them in their own category', () => { expect( buildDataTelemetryPayload([ From 23e18b93eb6b75d02724f7cd197ea1877a91da05 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Wed, 14 Apr 2021 13:48:00 +0300 Subject: [PATCH 65/90] [TSVB] Enable brush for visualizations created with no index patterns (#96727) * [TSVB] Enable brush for visualizations created with no index patterns * Fix comments typo Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/timeseries_visualization.tsx | 52 +++++++++++++------ 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/src/plugins/vis_type_timeseries/public/application/components/timeseries_visualization.tsx b/src/plugins/vis_type_timeseries/public/application/components/timeseries_visualization.tsx index 7fba2e1cb701fc..13d06e1c9a18de 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/timeseries_visualization.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/timeseries_visualization.tsx @@ -59,22 +59,44 @@ function TimeseriesVisualization({ const indexPatternValue = model.index_pattern || ''; const { indexPatterns } = getDataStart(); const { indexPattern } = await fetchIndexPattern(indexPatternValue, indexPatterns); + let event; + // trigger applyFilter if no index pattern found, url drilldowns are supported only + // for the index pattern mode + if (indexPattern) { + const tables = indexPattern + ? await convertSeriesToDataTable(model, series, indexPattern) + : null; + const table = tables?.[model.series[0].id]; + + const range: [number, number] = [parseInt(gte, 10), parseInt(lte, 10)]; + event = { + data: { + table, + column: X_ACCESSOR_INDEX, + range, + timeFieldName: indexPattern?.timeFieldName, + }, + name: 'brush', + }; + } else { + event = { + name: 'applyFilter', + data: { + timeFieldName: '*', + filters: [ + { + range: { + '*': { + gte, + lte, + }, + }, + }, + ], + }, + }; + } - const tables = indexPattern - ? await convertSeriesToDataTable(model, series, indexPattern) - : null; - const table = tables?.[model.series[0].id]; - - const range: [number, number] = [parseInt(gte, 10), parseInt(lte, 10)]; - const event = { - data: { - table, - column: X_ACCESSOR_INDEX, - range, - timeFieldName: indexPattern?.timeFieldName, - }, - name: 'brush', - }; handlers.event(event); }, [handlers, model] From e361e216223bf0a6a43d5fb0cd37e6c217815481 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Wed, 14 Apr 2021 14:12:27 +0200 Subject: [PATCH 66/90] UI actions readme (#96925) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: ✏️ improve UI actions plugin readme * docs: improve trigger description * docs: remove unnecessary comma * chore: 🤖 update autogenerated docs Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- docs/developer/plugin-list.asciidoc | 29 +++++++--- src/plugins/ui_actions/README.asciidoc | 73 +++++++++++++++++++++++--- 2 files changed, 86 insertions(+), 16 deletions(-) diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 0c40c2a8c4db94..353a77527d1d53 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -216,14 +216,27 @@ which also contains the timelion APIs and backend, look at the vis_type_timelion |<> -|An API for: - -- creating custom functionality (`actions`) -- creating custom user interaction events (`triggers`) -- attaching and detaching `actions` to `triggers`. -- emitting `trigger` events -- executing `actions` attached to a given `trigger`. -- exposing a context menu for the user to choose the appropriate action when there are multiple actions attached to a single trigger. +|UI Actions plugins provides API to manage *triggers* and *actions*. + +*Trigger* is an abstract description of user's intent to perform an action +(like user clicking on a value inside chart). It allows us to do runtime +binding between code from different plugins. For, example one such +trigger is when somebody applies filters on dashboard; another one is when +somebody opens a Dashboard panel context menu. + +*Actions* are pieces of code that execute in response to a trigger. For example, +to the dashboard filtering trigger multiple actions can be attached. Once a user +filters on the dashboard all possible actions are displayed to the user in a +popup menu and the user has to chose one. + +In general this plugin provides: + +- Creating custom functionality (actions). +- Creating custom user interaction events (triggers). +- Attaching and detaching actions to triggers. +- Emitting trigger events. +- Executing actions attached to a given trigger. +- Exposing a context menu for the user to choose the appropriate action when there are multiple actions attached to a single trigger. |{kib-repo}blob/{branch}/src/plugins/url_forwarding/README.md[urlForwarding] diff --git a/src/plugins/ui_actions/README.asciidoc b/src/plugins/ui_actions/README.asciidoc index 577aa2eae354b5..27b3eae3a52a7b 100644 --- a/src/plugins/ui_actions/README.asciidoc +++ b/src/plugins/ui_actions/README.asciidoc @@ -1,14 +1,71 @@ [[uiactions-plugin]] == UI Actions -An API for: - -- creating custom functionality (`actions`) -- creating custom user interaction events (`triggers`) -- attaching and detaching `actions` to `triggers`. -- emitting `trigger` events -- executing `actions` attached to a given `trigger`. -- exposing a context menu for the user to choose the appropriate action when there are multiple actions attached to a single trigger. +UI Actions plugins provides API to manage *triggers* and *actions*. + +*Trigger* is an abstract description of user's intent to perform an action +(like user clicking on a value inside chart). It allows us to do runtime +binding between code from different plugins. For, example one such +trigger is when somebody applies filters on dashboard; another one is when +somebody opens a Dashboard panel context menu. + +*Actions* are pieces of code that execute in response to a trigger. For example, +to the dashboard filtering trigger multiple actions can be attached. Once a user +filters on the dashboard all possible actions are displayed to the user in a +popup menu and the user has to chose one. + +In general this plugin provides: + +- Creating custom functionality (actions). +- Creating custom user interaction events (triggers). +- Attaching and detaching actions to triggers. +- Emitting trigger events. +- Executing actions attached to a given trigger. +- Exposing a context menu for the user to choose the appropriate action when there are multiple actions attached to a single trigger. + +=== Basic usage + +To get started, first you need to know a trigger you will attach your actions to. +You can either pick an existing one, or register your own one: + +[source,typescript jsx] +---- +plugins.uiActions.registerTrigger({ + id: 'MY_APP_PIE_CHART_CLICK', + title: 'Pie chart click', + description: 'When user clicks on a pie chart slice.', +}); +---- + +Now, when user clicks on a pie slice you need to "trigger" your trigger and +provide some context data: + +[source,typescript jsx] +---- +plugins.uiActions.getTrigger('MY_APP_PIE_CHART_CLICK').exec({ + /* Custom context data. */ +}); +---- + +Finally, your code or developers from other plugins can register UI actions that +listen for the above trigger and execute some code when the trigger is triggered. + +[source,typescript jsx] +---- +plugins.uiActions.registerAction({ + id: 'DO_SOMETHING', + isCompatible: async (context) => true, + execute: async (context) => { + // Do something. + }, +}); +plugins.uiActions.attachAction('MY_APP_PIE_CHART_CLICK', 'DO_SOMETHING'); +---- + +Now your `DO_SOMETHING` action will automatically execute when `MY_APP_PIE_CHART_CLICK` +trigger is triggered; or, if more than one compatible action is attached to +that trigger, user will be presented with a context menu popup to select one +action to execute. === Examples From 69f570f06aa0a4f7869bce56c9b0b25a50506214 Mon Sep 17 00:00:00 2001 From: Ahmad Bamieh Date: Wed, 14 Apr 2021 15:21:11 +0300 Subject: [PATCH 67/90] [Usage collection] Usage counters (#96696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Alejandro Fernández Haro --- ...rver.indexpatternsserviceprovider.start.md | 4 +- src/plugins/data/server/server.api.md | 1 + .../server/__snapshots__/index.test.ts.snap | 21 -- ...emetry_application_usage_collector.test.ts | 2 +- .../cloud/cloud_provider_collector.test.ts | 14 +- .../server/collectors/core/index.test.ts | 2 +- .../server/collectors/index.ts | 4 + .../server/collectors/kibana/index.test.ts | 2 +- .../telemetry_management_collector.test.ts | 2 +- .../server/collectors/ops_stats/index.test.ts | 2 +- .../__fixtures__/ui_counter_saved_objects.ts | 51 ++++ .../usage_counter_saved_objects.ts | 104 +++++++ .../register_ui_counters_collector.test.ts | 264 +++++++++++++---- .../register_ui_counters_collector.ts | 156 ++++++++-- .../ui_counters/rollups/register_rollups.ts | 12 +- .../ui_counters/rollups/rollups.test.ts | 38 ++- .../collectors/ui_counters/rollups/rollups.ts | 16 + .../server/collectors/ui_metric/index.test.ts | 2 +- .../usage_counter_saved_objects.ts | 104 +++++++ .../server/collectors/usage_counters/index.ts | 10 + .../register_usage_counters_collector.test.ts | 55 ++++ .../register_usage_counters_collector.ts | 116 ++++++++ .../usage_counters/rollups/constants.ts | 22 ++ .../usage_counters/rollups/index.ts | 9 + .../rollups/register_rollups.ts | 21 ++ .../usage_counters/rollups/rollups.test.ts | 170 +++++++++++ .../usage_counters/rollups/rollups.ts | 73 +++++ .../server/{index.test.mocks.ts => mocks.ts} | 0 .../server/{index.test.ts => plugin.test.ts} | 66 ++++- .../kibana_usage_collection/server/plugin.ts | 20 +- src/plugins/telemetry/schema/oss_plugins.json | 47 +++ src/plugins/usage_collection/README.mdx | 95 ++++++ .../usage_collection/common/ui_counters.ts | 23 ++ .../server/collector/collector_set.ts | 32 +- .../server/collector/index.ts | 1 - src/plugins/usage_collection/server/config.ts | 5 + src/plugins/usage_collection/server/index.ts | 13 + src/plugins/usage_collection/server/mocks.ts | 67 ++++- src/plugins/usage_collection/server/plugin.ts | 85 +++++- .../server/report/store_report.test.ts | 84 ++++-- .../server/report/store_report.ts | 18 +- .../usage_collection/server/routes/index.ts | 6 +- .../server/routes/ui_counters.ts | 6 +- .../server/usage_collection.mock.ts | 58 ---- .../server/usage_counters/index.ts | 15 + .../usage_counters/saved_objects.test.ts | 71 +++++ .../server/usage_counters/saved_objects.ts | 86 ++++++ .../usage_counters/usage_counter.test.ts | 38 +++ .../server/usage_counters/usage_counter.ts | 48 +++ .../usage_counters_service.mock.ts | 40 +++ .../usage_counters_service.test.ts | 241 +++++++++++++++ .../usage_counters/usage_counters_service.ts | 185 ++++++++++++ .../register_usage_collector.test.ts | 6 +- .../register_timeseries_collector.test.ts | 2 +- .../register_vega_collector.test.ts | 2 +- .../register_visualizations_collector.test.ts | 2 +- .../telemetry/__fixtures__/ui_counters.ts | 8 + .../telemetry/__fixtures__/usage_counters.ts | 36 +++ .../apis/telemetry/telemetry_local.ts | 15 + .../apis/ui_counters/ui_counters.ts | 50 ++-- test/api_integration/config.js | 2 + .../saved_objects/ui_counters/data.json | 111 +++++++ .../saved_objects/ui_counters/data.json.gz | Bin 236 -> 0 bytes .../saved_objects/ui_counters/mappings.json | 9 + .../saved_objects/usage_counters/data.json | 89 ++++++ .../usage_counters/mappings.json | 276 ++++++++++++++++++ test/plugin_functional/config.ts | 3 + .../plugins/usage_collection/kibana.json | 9 + .../plugins/usage_collection/package.json | 14 + .../plugins/usage_collection/server/index.ts | 10 + .../plugins/usage_collection/server/plugin.ts | 43 +++ .../plugins/usage_collection/server/routes.ts | 24 ++ .../plugins/usage_collection/tsconfig.json | 18 ++ .../test_suites/usage_collection/index.ts | 15 + .../usage_collection/usage_counters.ts | 67 +++++ 75 files changed, 3120 insertions(+), 318 deletions(-) delete mode 100644 src/plugins/kibana_usage_collection/server/__snapshots__/index.test.ts.snap create mode 100644 src/plugins/kibana_usage_collection/server/collectors/ui_counters/__fixtures__/ui_counter_saved_objects.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/ui_counters/__fixtures__/usage_counter_saved_objects.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/usage_counters/__fixtures__/usage_counter_saved_objects.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/usage_counters/index.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/usage_counters/register_usage_counters_collector.test.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/usage_counters/register_usage_counters_collector.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/constants.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/index.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/register_rollups.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/rollups.test.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/rollups.ts rename src/plugins/kibana_usage_collection/server/{index.test.mocks.ts => mocks.ts} (100%) rename src/plugins/kibana_usage_collection/server/{index.test.ts => plugin.test.ts} (59%) create mode 100644 src/plugins/usage_collection/common/ui_counters.ts delete mode 100644 src/plugins/usage_collection/server/usage_collection.mock.ts create mode 100644 src/plugins/usage_collection/server/usage_counters/index.ts create mode 100644 src/plugins/usage_collection/server/usage_counters/saved_objects.test.ts create mode 100644 src/plugins/usage_collection/server/usage_counters/saved_objects.ts create mode 100644 src/plugins/usage_collection/server/usage_counters/usage_counter.test.ts create mode 100644 src/plugins/usage_collection/server/usage_counters/usage_counter.ts create mode 100644 src/plugins/usage_collection/server/usage_counters/usage_counters_service.mock.ts create mode 100644 src/plugins/usage_collection/server/usage_counters/usage_counters_service.test.ts create mode 100644 src/plugins/usage_collection/server/usage_counters/usage_counters_service.ts create mode 100644 test/api_integration/apis/telemetry/__fixtures__/usage_counters.ts create mode 100644 test/api_integration/fixtures/es_archiver/saved_objects/ui_counters/data.json delete mode 100644 test/api_integration/fixtures/es_archiver/saved_objects/ui_counters/data.json.gz create mode 100644 test/api_integration/fixtures/es_archiver/saved_objects/usage_counters/data.json create mode 100644 test/api_integration/fixtures/es_archiver/saved_objects/usage_counters/mappings.json create mode 100644 test/plugin_functional/plugins/usage_collection/kibana.json create mode 100644 test/plugin_functional/plugins/usage_collection/package.json create mode 100644 test/plugin_functional/plugins/usage_collection/server/index.ts create mode 100644 test/plugin_functional/plugins/usage_collection/server/plugin.ts create mode 100644 test/plugin_functional/plugins/usage_collection/server/routes.ts create mode 100644 test/plugin_functional/plugins/usage_collection/tsconfig.json create mode 100644 test/plugin_functional/test_suites/usage_collection/index.ts create mode 100644 test/plugin_functional/test_suites/usage_collection/usage_counters.ts diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsserviceprovider.start.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsserviceprovider.start.md index 88079bb2fa3cb6..118b0104fbee64 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsserviceprovider.start.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsserviceprovider.start.md @@ -8,7 +8,7 @@ ```typescript start(core: CoreStart, { fieldFormats, logger }: IndexPatternsServiceStartDeps): { - indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: ElasticsearchClient) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: ElasticsearchClient) => Promise; }; ``` @@ -22,6 +22,6 @@ start(core: CoreStart, { fieldFormats, logger }: IndexPatternsServiceStartDeps): Returns: `{ - indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: ElasticsearchClient) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: ElasticsearchClient) => Promise; }` diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 0ea3af60e9b5d3..622356c4441ac3 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -56,6 +56,7 @@ import { PublicMethodsOf } from '@kbn/utility-types'; import { RecursiveReadonly } from '@kbn/utility-types'; import { RequestAdapter } from 'src/plugins/inspector/common'; import { RequestHandlerContext } from 'src/core/server'; +import * as Rx from 'rxjs'; import { SavedObject } from 'kibana/server'; import { SavedObject as SavedObject_2 } from 'src/core/server'; import { SavedObjectsClientContract } from 'src/core/server'; diff --git a/src/plugins/kibana_usage_collection/server/__snapshots__/index.test.ts.snap b/src/plugins/kibana_usage_collection/server/__snapshots__/index.test.ts.snap deleted file mode 100644 index 939e90d2f25831..00000000000000 --- a/src/plugins/kibana_usage_collection/server/__snapshots__/index.test.ts.snap +++ /dev/null @@ -1,21 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`kibana_usage_collection Runs the setup method without issues 1`] = `true`; - -exports[`kibana_usage_collection Runs the setup method without issues 2`] = `false`; - -exports[`kibana_usage_collection Runs the setup method without issues 3`] = `true`; - -exports[`kibana_usage_collection Runs the setup method without issues 4`] = `false`; - -exports[`kibana_usage_collection Runs the setup method without issues 5`] = `false`; - -exports[`kibana_usage_collection Runs the setup method without issues 6`] = `false`; - -exports[`kibana_usage_collection Runs the setup method without issues 7`] = `false`; - -exports[`kibana_usage_collection Runs the setup method without issues 8`] = `true`; - -exports[`kibana_usage_collection Runs the setup method without issues 9`] = `false`; - -exports[`kibana_usage_collection Runs the setup method without issues 10`] = `true`; diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts index f1b21af5506e6e..da4e1b101914f2 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts @@ -10,7 +10,7 @@ import { savedObjectsRepositoryMock, loggingSystemMock } from '../../../../../co import { Collector, createUsageCollectionSetupMock, -} from '../../../../usage_collection/server/usage_collection.mock'; +} from '../../../../usage_collection/server/mocks'; import { MAIN_APP_DEFAULT_VIEW_ID } from '../../../../usage_collection/common/constants'; import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks'; import { diff --git a/src/plugins/kibana_usage_collection/server/collectors/cloud/cloud_provider_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/cloud/cloud_provider_collector.test.ts index 1f7617a0e69ce8..a2f08ddb465cc7 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/cloud/cloud_provider_collector.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/cloud/cloud_provider_collector.test.ts @@ -12,25 +12,23 @@ import { Collector, createUsageCollectionSetupMock, createCollectorFetchContextMock, -} from '../../../../usage_collection/server/usage_collection.mock'; +} from '../../../../usage_collection/server/mocks'; import { registerCloudProviderUsageCollector } from './cloud_provider_collector'; describe('registerCloudProviderUsageCollector', () => { let collector: Collector; const logger = loggingSystemMock.createLogger(); - - const usageCollectionMock = createUsageCollectionSetupMock(); - usageCollectionMock.makeUsageCollector.mockImplementation((config) => { - collector = new Collector(logger, config); - return createUsageCollectionSetupMock().makeUsageCollector(config); - }); - const mockedFetchContext = createCollectorFetchContextMock(); beforeEach(() => { cloudDetailsMock.mockClear(); detectCloudServiceMock.mockClear(); + const usageCollectionMock = createUsageCollectionSetupMock(); + usageCollectionMock.makeUsageCollector.mockImplementation((config) => { + collector = new Collector(logger, config); + return createUsageCollectionSetupMock().makeUsageCollector(config); + }); registerCloudProviderUsageCollector(usageCollectionMock); }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/core/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/core/index.test.ts index 4409442f4c70ad..cbc38129fdddf2 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/core/index.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/core/index.test.ts @@ -9,7 +9,7 @@ import { Collector, createUsageCollectionSetupMock, -} from '../../../../usage_collection/server/usage_collection.mock'; +} from '../../../../usage_collection/server/mocks'; import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks'; import { registerCoreUsageCollector } from '.'; import { coreUsageDataServiceMock, loggingSystemMock } from '../../../../../core/server/mocks'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/index.ts b/src/plugins/kibana_usage_collection/server/collectors/index.ts index 89e1e6e79482ce..522860e58918cd 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/index.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/index.ts @@ -20,3 +20,7 @@ export { registerUiCounterSavedObjectType, registerUiCountersRollups, } from './ui_counters'; +export { + registerUsageCountersRollups, + registerUsageCountersUsageCollector, +} from './usage_counters'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/kibana/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/kibana/index.test.ts index 1d0329cb01d69e..e1afbfbcecc4ec 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/kibana/index.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/kibana/index.test.ts @@ -15,7 +15,7 @@ import { Collector, createCollectorFetchContextMock, createUsageCollectionSetupMock, -} from '../../../../usage_collection/server/usage_collection.mock'; +} from '../../../../usage_collection/server/mocks'; import { registerKibanaUsageCollector } from './'; const logger = loggingSystemMock.createLogger(); diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.test.ts index a8ac778226082c..cb0b1c045397db 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.test.ts @@ -11,7 +11,7 @@ import { Collector, createUsageCollectionSetupMock, createCollectorFetchContextMock, -} from '../../../../usage_collection/server/usage_collection.mock'; +} from '../../../../usage_collection/server/mocks'; import { registerManagementUsageCollector, diff --git a/src/plugins/kibana_usage_collection/server/collectors/ops_stats/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/ops_stats/index.test.ts index a90197e7a25ab4..dfd6a93b7ea184 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/ops_stats/index.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/ops_stats/index.test.ts @@ -11,7 +11,7 @@ import { Collector, createUsageCollectionSetupMock, createCollectorFetchContextMock, -} from '../../../../usage_collection/server/usage_collection.mock'; +} from '../../../../usage_collection/server/mocks'; import { registerOpsStatsCollector } from './'; import { OpsMetrics } from '../../../../../core/server'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/ui_counters/__fixtures__/ui_counter_saved_objects.ts b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/__fixtures__/ui_counter_saved_objects.ts new file mode 100644 index 00000000000000..ebc958c7be8c6a --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/__fixtures__/ui_counter_saved_objects.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { UICounterSavedObject } from '../ui_counter_saved_object_type'; +export const rawUiCounters: UICounterSavedObject[] = [ + { + type: 'ui-counter', + id: 'Kibana_home:23102020:click:different_type', + attributes: { + count: 1, + }, + references: [], + updated_at: '2020-11-24T11:27:57.067Z', + version: 'WzI5NDRd', + }, + { + type: 'ui-counter', + id: 'Kibana_home:25102020:loaded:intersecting_event', + attributes: { + count: 1, + }, + references: [], + updated_at: '2020-10-25T11:27:57.067Z', + version: 'WzI5NDRd', + }, + { + type: 'ui-counter', + id: 'Kibana_home:23102020:loaded:intersecting_event', + attributes: { + count: 3, + }, + references: [], + updated_at: '2020-10-23T11:27:57.067Z', + version: 'WzI5NDRd', + }, + { + type: 'ui-counter', + id: 'Kibana_home:24112020:click:only_reported_in_ui_counters', + attributes: { + count: 1, + }, + references: [], + updated_at: '2020-11-24T11:27:57.067Z', + version: 'WzI5NDRd', + }, +]; diff --git a/src/plugins/kibana_usage_collection/server/collectors/ui_counters/__fixtures__/usage_counter_saved_objects.ts b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/__fixtures__/usage_counter_saved_objects.ts new file mode 100644 index 00000000000000..6b70a8c97e651d --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/__fixtures__/usage_counter_saved_objects.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { UsageCountersSavedObject } from '../../../../../usage_collection/server'; + +export const rawUsageCounters: UsageCountersSavedObject[] = [ + { + type: 'usage-counters', + id: 'uiCounter:09042021:count:myApp:my_event', + attributes: { + count: 1, + counterName: 'myApp:my_event', + counterType: 'count', + domainId: 'uiCounter', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-09T08:17:57.693Z', + }, + { + type: 'usage-counters', + id: 'uiCounter:23102020:loaded:Kibana_home:intersecting_event', + attributes: { + count: 60, + counterName: 'Kibana_home:intersecting_event', + counterType: 'loaded', + domainId: 'uiCounter', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2020-10-23T11:27:57.067Z', + }, + { + type: 'usage-counters', + id: 'uiCounter:09042021:count:myApp:my_event_4457914848544', + attributes: { + count: 0, + counterName: 'myApp:my_event_4457914848544', + counterType: 'count', + domainId: 'uiCounter', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-09T08:18:03.030Z', + }, + { + type: 'usage-counters', + id: 'uiCounter:09042021:count:myApp:my_event_malformed', + attributes: { + // @ts-expect-error + count: 'malformed', + counterName: 'myApp:my_event_malformed', + counterType: 'count', + domainId: 'uiCounter', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-09T08:18:03.030Z', + }, + { + type: 'usage-counters', + id: 'anotherDomainId:09042021:count:some_event_name', + attributes: { + count: 4, + counterName: 'some_event_name', + counterType: 'count', + domainId: 'anotherDomainId', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-09T08:18:03.030Z', + }, + { + type: 'usage-counters', + id: 'uiCounter:09042021:count:myApp:my_event_4457914848544_2', + attributes: { + count: 8, + counterName: 'myApp:my_event_4457914848544_2', + counterType: 'count', + domainId: 'uiCounter', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-09T08:18:03.031Z', + }, + { + type: 'usage-counters', + id: 'uiCounter:09042021:count:myApp:only_reported_in_usage_counters', + attributes: { + count: 1, + counterName: 'myApp:only_reported_in_usage_counters', + counterType: 'count', + domainId: 'uiCounter', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-09T08:18:03.031Z', + }, +]; diff --git a/src/plugins/kibana_usage_collection/server/collectors/ui_counters/register_ui_counters_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/register_ui_counters_collector.test.ts index 7e84bc852c9b5b..122e637d2b20c7 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/ui_counters/register_ui_counters_collector.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/register_ui_counters_collector.test.ts @@ -6,70 +6,208 @@ * Side Public License, v 1. */ -import { transformRawCounter } from './register_ui_counters_collector'; -import { UICounterSavedObject } from './ui_counter_saved_object_type'; +import { + transformRawUiCounterObject, + transformRawUsageCounterObject, + createFetchUiCounters, +} from './register_ui_counters_collector'; +import { BehaviorSubject } from 'rxjs'; +import { rawUiCounters } from './__fixtures__/ui_counter_saved_objects'; +import { rawUsageCounters } from './__fixtures__/usage_counter_saved_objects'; +import { savedObjectsClientMock } from '../../../../../core/server/mocks'; +import { UI_COUNTER_SAVED_OBJECT_TYPE } from './ui_counter_saved_object_type'; +import { USAGE_COUNTERS_SAVED_OBJECT_TYPE } from '../../../../usage_collection/server'; -describe('transformRawCounter', () => { - const mockRawUiCounters = [ - { - type: 'ui-counter', - id: 'Kibana_home:24112020:click:ingest_data_card_home_tutorial_directory', - attributes: { - count: 3, - }, - references: [], - updated_at: '2020-11-24T11:27:57.067Z', - version: 'WzI5LDFd', - }, - { - type: 'ui-counter', - id: 'Kibana_home:24112020:click:home_tutorial_directory', - attributes: { - count: 1, - }, - references: [], - updated_at: '2020-11-24T11:27:57.067Z', - version: 'WzI5NDRd', - }, - { - type: 'ui-counter', - id: 'Kibana_home:24112020:loaded:home_tutorial_directory', - attributes: { - count: 3, - }, - references: [], - updated_at: '2020-10-23T11:27:57.067Z', - version: 'WzI5NDRd', - }, - ] as UICounterSavedObject[]; +describe('transformRawUsageCounterObject', () => { + it('transforms usage counters savedObject raw entries', () => { + const result = rawUsageCounters.map(transformRawUsageCounterObject); + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "appName": "myApp", + "counterType": "count", + "eventName": "my_event", + "fromTimestamp": "2021-04-09T00:00:00Z", + "lastUpdatedAt": "2021-04-09T08:17:57.693Z", + "total": 1, + }, + Object { + "appName": "Kibana_home", + "counterType": "loaded", + "eventName": "intersecting_event", + "fromTimestamp": "2020-10-23T00:00:00Z", + "lastUpdatedAt": "2020-10-23T11:27:57.067Z", + "total": 60, + }, + undefined, + undefined, + undefined, + Object { + "appName": "myApp", + "counterType": "count", + "eventName": "my_event_4457914848544_2", + "fromTimestamp": "2021-04-09T00:00:00Z", + "lastUpdatedAt": "2021-04-09T08:18:03.031Z", + "total": 8, + }, + Object { + "appName": "myApp", + "counterType": "count", + "eventName": "only_reported_in_usage_counters", + "fromTimestamp": "2021-04-09T00:00:00Z", + "lastUpdatedAt": "2021-04-09T08:18:03.031Z", + "total": 1, + }, + ] + `); + }); +}); + +describe('transformRawUiCounterObject', () => { + it('transforms ui counters savedObject raw entries', () => { + const result = rawUiCounters.map(transformRawUiCounterObject); + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "appName": "Kibana_home", + "counterType": "click", + "eventName": "different_type", + "fromTimestamp": "2020-11-24T00:00:00Z", + "lastUpdatedAt": "2020-11-24T11:27:57.067Z", + "total": 1, + }, + Object { + "appName": "Kibana_home", + "counterType": "loaded", + "eventName": "intersecting_event", + "fromTimestamp": "2020-10-25T00:00:00Z", + "lastUpdatedAt": "2020-10-25T11:27:57.067Z", + "total": 1, + }, + Object { + "appName": "Kibana_home", + "counterType": "loaded", + "eventName": "intersecting_event", + "fromTimestamp": "2020-10-23T00:00:00Z", + "lastUpdatedAt": "2020-10-23T11:27:57.067Z", + "total": 3, + }, + Object { + "appName": "Kibana_home", + "counterType": "click", + "eventName": "only_reported_in_ui_counters", + "fromTimestamp": "2020-11-24T00:00:00Z", + "lastUpdatedAt": "2020-11-24T11:27:57.067Z", + "total": 1, + }, + ] + `); + }); +}); + +describe('createFetchUiCounters', () => { + let stopUsingUiCounterIndicies$: BehaviorSubject; + const soClientMock = savedObjectsClientMock.create(); + beforeEach(() => { + jest.clearAllMocks(); + stopUsingUiCounterIndicies$ = new BehaviorSubject(false); + }); + + it('does not query ui_counters saved objects if stopUsingUiCounterIndicies$ is complete', async () => { + // @ts-expect-error incomplete mock implementation + soClientMock.find.mockImplementation(async ({ type }) => { + switch (type) { + case USAGE_COUNTERS_SAVED_OBJECT_TYPE: + return { saved_objects: rawUsageCounters }; + default: + throw new Error(`unexpected type ${type}`); + } + }); + + stopUsingUiCounterIndicies$.complete(); + // @ts-expect-error incomplete mock implementation + const { dailyEvents } = await createFetchUiCounters(stopUsingUiCounterIndicies$)({ + soClient: soClientMock, + }); + + const transforemdUsageCounters = rawUsageCounters.map(transformRawUsageCounterObject); + expect(soClientMock.find).toBeCalledTimes(1); + expect(dailyEvents).toEqual(transforemdUsageCounters.filter(Boolean)); + }); + + it('merges saved objects from both ui_counters and usage_counters saved objects', async () => { + // @ts-expect-error incomplete mock implementation + soClientMock.find.mockImplementation(async ({ type }) => { + switch (type) { + case UI_COUNTER_SAVED_OBJECT_TYPE: + return { saved_objects: rawUiCounters }; + case USAGE_COUNTERS_SAVED_OBJECT_TYPE: + return { saved_objects: rawUsageCounters }; + default: + throw new Error(`unexpected type ${type}`); + } + }); + + // @ts-expect-error incomplete mock implementation + const { dailyEvents } = await createFetchUiCounters(stopUsingUiCounterIndicies$)({ + soClient: soClientMock, + }); + expect(dailyEvents).toHaveLength(7); + const intersectingEntry = dailyEvents.find( + ({ eventName, fromTimestamp }) => + eventName === 'intersecting_event' && fromTimestamp === '2020-10-23T00:00:00Z' + ); + + const onlyFromUICountersEntry = dailyEvents.find( + ({ eventName }) => eventName === 'only_reported_in_ui_counters' + ); + + const onlyFromUsageCountersEntry = dailyEvents.find( + ({ eventName }) => eventName === 'only_reported_in_usage_counters' + ); + + const invalidCountEntry = dailyEvents.find( + ({ eventName }) => eventName === 'my_event_malformed' + ); + + const zeroCountEntry = dailyEvents.find( + ({ eventName }) => eventName === 'my_event_4457914848544' + ); + + const nonUiCountersEntry = dailyEvents.find(({ eventName }) => eventName === 'some_event_name'); - it('transforms saved object raw entries', () => { - const result = mockRawUiCounters.map(transformRawCounter); - expect(result).toEqual([ - { - appName: 'Kibana_home', - eventName: 'ingest_data_card_home_tutorial_directory', - lastUpdatedAt: '2020-11-24T11:27:57.067Z', - fromTimestamp: '2020-11-24T00:00:00Z', - counterType: 'click', - total: 3, - }, - { - appName: 'Kibana_home', - eventName: 'home_tutorial_directory', - lastUpdatedAt: '2020-11-24T11:27:57.067Z', - fromTimestamp: '2020-11-24T00:00:00Z', - counterType: 'click', - total: 1, - }, - { - appName: 'Kibana_home', - eventName: 'home_tutorial_directory', - lastUpdatedAt: '2020-10-23T11:27:57.067Z', - fromTimestamp: '2020-10-23T00:00:00Z', - counterType: 'loaded', - total: 3, - }, - ]); + expect(invalidCountEntry).toBe(undefined); + expect(nonUiCountersEntry).toBe(undefined); + expect(zeroCountEntry).toBe(undefined); + expect(onlyFromUICountersEntry).toMatchInlineSnapshot(` + Object { + "appName": "Kibana_home", + "counterType": "click", + "eventName": "only_reported_in_ui_counters", + "fromTimestamp": "2020-11-24T00:00:00Z", + "lastUpdatedAt": "2020-11-24T11:27:57.067Z", + "total": 1, + } + `); + expect(onlyFromUsageCountersEntry).toMatchInlineSnapshot(` + Object { + "appName": "myApp", + "counterType": "count", + "eventName": "only_reported_in_usage_counters", + "fromTimestamp": "2021-04-09T00:00:00Z", + "lastUpdatedAt": "2021-04-09T08:18:03.031Z", + "total": 1, + } + `); + expect(intersectingEntry).toMatchInlineSnapshot(` + Object { + "appName": "Kibana_home", + "counterType": "loaded", + "eventName": "intersecting_event", + "fromTimestamp": "2020-10-23T00:00:00Z", + "lastUpdatedAt": "2020-10-23T11:27:57.067Z", + "total": 63, + } + `); }); }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/ui_counters/register_ui_counters_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/register_ui_counters_collector.ts index dc3fac73820949..19190de45d96b8 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/ui_counters/register_ui_counters_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/register_ui_counters_collector.ts @@ -7,13 +7,28 @@ */ import moment from 'moment'; -import { CollectorFetchContext, UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { mergeWith } from 'lodash'; +import type { Subject } from 'rxjs'; import { UICounterSavedObject, UICounterSavedObjectAttributes, UI_COUNTER_SAVED_OBJECT_TYPE, } from './ui_counter_saved_object_type'; +import { + CollectorFetchContext, + UsageCollectionSetup, + USAGE_COUNTERS_SAVED_OBJECT_TYPE, + UsageCountersSavedObject, + UsageCountersSavedObjectAttributes, + serializeCounterKey, +} from '../../../../usage_collection/server'; + +import { + deserializeUiCounterName, + serializeUiCounterName, +} from '../../../../usage_collection/common/ui_counters'; + interface UiCounterEvent { appName: string; eventName: string; @@ -27,12 +42,20 @@ export interface UiCountersUsage { dailyEvents: UiCounterEvent[]; } -export function transformRawCounter(rawUiCounter: UICounterSavedObject) { - const { id, attributes, updated_at: lastUpdatedAt } = rawUiCounter; +export function transformRawUiCounterObject( + rawUiCounter: UICounterSavedObject +): UiCounterEvent | undefined { + const { + id, + attributes: { count }, + updated_at: lastUpdatedAt, + } = rawUiCounter; + if (typeof count !== 'number' || count < 1) { + return; + } + const [appName, , counterType, ...restId] = id.split(':'); const eventName = restId.join(':'); - const counterTotal: unknown = attributes.count; - const total = typeof counterTotal === 'number' ? counterTotal : 0; const fromTimestamp = moment(lastUpdatedAt).utc().startOf('day').format(); return { @@ -41,11 +64,110 @@ export function transformRawCounter(rawUiCounter: UICounterSavedObject) { lastUpdatedAt, fromTimestamp, counterType, - total, + total: count, + }; +} + +export function transformRawUsageCounterObject( + rawUsageCounter: UsageCountersSavedObject +): UiCounterEvent | undefined { + const { + attributes: { count, counterName, counterType, domainId }, + updated_at: lastUpdatedAt, + } = rawUsageCounter; + + if (domainId !== 'uiCounter' || typeof count !== 'number' || count < 1) { + return; + } + + const fromTimestamp = moment(lastUpdatedAt).utc().startOf('day').format(); + const { appName, eventName } = deserializeUiCounterName(counterName); + + return { + appName, + eventName, + lastUpdatedAt, + fromTimestamp, + counterType, + total: count, }; } -export function registerUiCountersUsageCollector(usageCollection: UsageCollectionSetup) { +export const createFetchUiCounters = (stopUsingUiCounterIndicies$: Subject) => + async function fetchUiCounters({ soClient }: CollectorFetchContext) { + const { + saved_objects: rawUsageCounters, + } = await soClient.find({ + type: USAGE_COUNTERS_SAVED_OBJECT_TYPE, + fields: ['count', 'counterName', 'counterType', 'domainId'], + filter: `${USAGE_COUNTERS_SAVED_OBJECT_TYPE}.attributes.domainId: uiCounter`, + perPage: 10000, + }); + + const skipFetchingUiCounters = stopUsingUiCounterIndicies$.isStopped; + const result = + skipFetchingUiCounters || + (await soClient.find({ + type: UI_COUNTER_SAVED_OBJECT_TYPE, + fields: ['count'], + perPage: 10000, + })); + + const rawUiCounters = typeof result === 'object' ? result.saved_objects : []; + const dailyEventsFromUiCounters = rawUiCounters.reduce((acc, raw) => { + try { + const event = transformRawUiCounterObject(raw); + if (event) { + const { appName, eventName, counterType } = event; + const key = serializeCounterKey({ + domainId: 'uiCounter', + counterName: serializeUiCounterName({ appName, eventName }), + counterType, + date: event.lastUpdatedAt, + }); + + acc[key] = event; + } + } catch (_) { + // swallow error; allows sending successfully transformed objects. + } + return acc; + }, {} as Record); + + const dailyEventsFromUsageCounters = rawUsageCounters.reduce((acc, raw) => { + try { + const event = transformRawUsageCounterObject(raw); + if (event) { + acc[raw.id] = event; + } + } catch (_) { + // swallow error; allows sending successfully transformed objects. + } + return acc; + }, {} as Record); + + const mergedDailyCounters = mergeWith( + dailyEventsFromUsageCounters, + dailyEventsFromUiCounters, + (value: UiCounterEvent | undefined, srcValue: UiCounterEvent): UiCounterEvent => { + if (!value) { + return srcValue; + } + + return { + ...srcValue, + total: srcValue.total + value.total, + }; + } + ); + + return { dailyEvents: Object.values(mergedDailyCounters) }; + }; + +export function registerUiCountersUsageCollector( + usageCollection: UsageCollectionSetup, + stopUsingUiCounterIndicies$: Subject +) { const collector = usageCollection.makeUsageCollector({ type: 'ui_counters', schema: { @@ -76,25 +198,7 @@ export function registerUiCountersUsageCollector(usageCollection: UsageCollectio }, }, }, - fetch: async ({ soClient }: CollectorFetchContext) => { - const { saved_objects: rawUiCounters } = await soClient.find({ - type: UI_COUNTER_SAVED_OBJECT_TYPE, - fields: ['count'], - perPage: 10000, - }); - - return { - dailyEvents: rawUiCounters.reduce((acc, raw) => { - try { - const aggEvent = transformRawCounter(raw); - acc.push(aggEvent); - } catch (_) { - // swallow error; allows sending successfully transformed objects. - } - return acc; - }, [] as UiCounterEvent[]), - }; - }, + fetch: createFetchUiCounters(stopUsingUiCounterIndicies$), isReady: () => true, }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/ui_counters/rollups/register_rollups.ts b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/rollups/register_rollups.ts index 9595101efb63bb..55da239d8ef2a1 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/ui_counters/rollups/register_rollups.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/rollups/register_rollups.ts @@ -6,16 +6,20 @@ * Side Public License, v 1. */ -import { timer } from 'rxjs'; +import { Subject, timer } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; import { Logger, ISavedObjectsRepository } from 'kibana/server'; import { ROLL_INDICES_INTERVAL, ROLL_INDICES_START } from './constants'; import { rollUiCounterIndices } from './rollups'; export function registerUiCountersRollups( logger: Logger, + stopRollingUiCounterIndicies$: Subject, getSavedObjectsClient: () => ISavedObjectsRepository | undefined ) { - timer(ROLL_INDICES_START, ROLL_INDICES_INTERVAL).subscribe(() => - rollUiCounterIndices(logger, getSavedObjectsClient()) - ); + timer(ROLL_INDICES_START, ROLL_INDICES_INTERVAL) + .pipe(takeUntil(stopRollingUiCounterIndicies$)) + .subscribe(() => + rollUiCounterIndices(logger, stopRollingUiCounterIndicies$, getSavedObjectsClient()) + ); } diff --git a/src/plugins/kibana_usage_collection/server/collectors/ui_counters/rollups/rollups.test.ts b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/rollups/rollups.test.ts index 5cb91f7f898c18..f69ddde6a65bd1 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/ui_counters/rollups/rollups.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/rollups/rollups.test.ts @@ -7,9 +7,11 @@ */ import moment from 'moment'; +import * as Rx from 'rxjs'; import { isSavedObjectOlderThan, rollUiCounterIndices } from './rollups'; import { savedObjectsRepositoryMock, loggingSystemMock } from '../../../../../../core/server/mocks'; import { SavedObjectsFindResult } from 'kibana/server'; + import { UICounterSavedObjectAttributes, UI_COUNTER_SAVED_OBJECT_TYPE, @@ -70,14 +72,18 @@ describe('isSavedObjectOlderThan', () => { describe('rollUiCounterIndices', () => { let logger: ReturnType; let savedObjectClient: ReturnType; + let stopUsingUiCounterIndicies$: Rx.Subject; beforeEach(() => { logger = loggingSystemMock.createLogger(); savedObjectClient = savedObjectsRepositoryMock.create(); + stopUsingUiCounterIndicies$ = new Rx.Subject(); }); it('returns undefined if no savedObjectsClient initialised yet', async () => { - await expect(rollUiCounterIndices(logger, undefined)).resolves.toBe(undefined); + await expect( + rollUiCounterIndices(logger, stopUsingUiCounterIndicies$, undefined) + ).resolves.toBe(undefined); expect(logger.warn).toHaveBeenCalledTimes(0); }); @@ -90,11 +96,27 @@ describe('rollUiCounterIndices', () => { throw new Error(`Unexpected type [${type}]`); } }); - await expect(rollUiCounterIndices(logger, savedObjectClient)).resolves.toEqual([]); + await expect( + rollUiCounterIndices(logger, stopUsingUiCounterIndicies$, savedObjectClient) + ).resolves.toEqual([]); expect(savedObjectClient.find).toBeCalled(); expect(savedObjectClient.delete).not.toBeCalled(); expect(logger.warn).toHaveBeenCalledTimes(0); }); + it('calls Subject complete() on empty saved objects', async () => { + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case UI_COUNTER_SAVED_OBJECT_TYPE: + return { saved_objects: [], total: 0, page, per_page: perPage }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + await expect( + rollUiCounterIndices(logger, stopUsingUiCounterIndicies$, savedObjectClient) + ).resolves.toEqual([]); + expect(stopUsingUiCounterIndicies$.isStopped).toBe(true); + }); it(`deletes documents older than ${UI_COUNTERS_KEEP_DOCS_FOR_DAYS} days`, async () => { const mockSavedObjects = [ @@ -111,7 +133,9 @@ describe('rollUiCounterIndices', () => { throw new Error(`Unexpected type [${type}]`); } }); - await expect(rollUiCounterIndices(logger, savedObjectClient)).resolves.toHaveLength(2); + await expect( + rollUiCounterIndices(logger, stopUsingUiCounterIndicies$, savedObjectClient) + ).resolves.toHaveLength(2); expect(savedObjectClient.find).toBeCalled(); expect(savedObjectClient.delete).toHaveBeenCalledTimes(2); expect(savedObjectClient.delete).toHaveBeenNthCalledWith( @@ -131,7 +155,9 @@ describe('rollUiCounterIndices', () => { savedObjectClient.find.mockImplementation(async () => { throw new Error(`Expected error!`); }); - await expect(rollUiCounterIndices(logger, savedObjectClient)).resolves.toEqual(undefined); + await expect( + rollUiCounterIndices(logger, stopUsingUiCounterIndicies$, savedObjectClient) + ).resolves.toEqual(undefined); expect(savedObjectClient.find).toBeCalled(); expect(savedObjectClient.delete).not.toBeCalled(); expect(logger.warn).toHaveBeenCalledTimes(2); @@ -151,7 +177,9 @@ describe('rollUiCounterIndices', () => { savedObjectClient.delete.mockImplementation(async () => { throw new Error(`Expected error!`); }); - await expect(rollUiCounterIndices(logger, savedObjectClient)).resolves.toEqual(undefined); + await expect( + rollUiCounterIndices(logger, stopUsingUiCounterIndicies$, savedObjectClient) + ).resolves.toEqual(undefined); expect(savedObjectClient.find).toBeCalled(); expect(savedObjectClient.delete).toHaveBeenCalledTimes(1); expect(savedObjectClient.delete).toHaveBeenNthCalledWith( diff --git a/src/plugins/kibana_usage_collection/server/collectors/ui_counters/rollups/rollups.ts b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/rollups/rollups.ts index 3a092f845c3a37..79e7d3e07ba46a 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/ui_counters/rollups/rollups.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/ui_counters/rollups/rollups.ts @@ -8,6 +8,7 @@ import { ISavedObjectsRepository, Logger } from 'kibana/server'; import moment from 'moment'; +import type { Subject } from 'rxjs'; import { UI_COUNTERS_KEEP_DOCS_FOR_DAYS } from './constants'; import { @@ -38,6 +39,7 @@ export function isSavedObjectOlderThan({ export async function rollUiCounterIndices( logger: Logger, + stopUsingUiCounterIndicies$: Subject, savedObjectsClient?: ISavedObjectsRepository ) { if (!savedObjectsClient) { @@ -54,6 +56,20 @@ export async function rollUiCounterIndices( } ); + if (rawUiCounterDocs.length === 0) { + /** + * @deprecated 7.13 to be removed in 8.0.0 + * Stop triggering rollups when we've rolled up all documents. + * + * This Saved Object registry is no longer used. + * Migration from one SO registry to another is not yet supported. + * In a future release we can remove this piece of code and + * migrate any docs to the Usage Counters Saved object. + */ + + stopUsingUiCounterIndicies$.complete(); + } + const docsToDelete = rawUiCounterDocs.filter((doc) => isSavedObjectOlderThan({ numberOfDays: UI_COUNTERS_KEEP_DOCS_FOR_DAYS, diff --git a/src/plugins/kibana_usage_collection/server/collectors/ui_metric/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/ui_metric/index.test.ts index 77413cc7d7d9d4..51ecbf736bfc14 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/ui_metric/index.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/ui_metric/index.test.ts @@ -11,7 +11,7 @@ import { Collector, createUsageCollectionSetupMock, createCollectorFetchContextMock, -} from '../../../../usage_collection/server/usage_collection.mock'; +} from '../../../../usage_collection/server/mocks'; import { registerUiMetricUsageCollector } from './'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/usage_counters/__fixtures__/usage_counter_saved_objects.ts b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/__fixtures__/usage_counter_saved_objects.ts new file mode 100644 index 00000000000000..d0a45fb86b1f89 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/__fixtures__/usage_counter_saved_objects.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { UsageCountersSavedObject } from '../../../../../usage_collection/server'; + +export const rawUsageCounters: UsageCountersSavedObject[] = [ + { + type: 'usage-counters', + id: 'uiCounter:09042021:count:myApp:my_event', + attributes: { + count: 13, + counterName: 'my_event', + counterType: 'count', + domainId: 'uiCounter', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-09T08:18:03.030Z', + }, + { + type: 'usage-counters', + id: 'anotherDomainId:09042021:count:some_event_name', + attributes: { + count: 4, + counterName: 'some_event_name', + counterType: 'count', + domainId: 'anotherDomainId', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-09T08:18:03.030Z', + }, + { + type: 'usage-counters', + id: 'anotherDomainId:09042021:count:some_event_name', + attributes: { + count: 4, + counterName: 'some_event_name', + counterType: 'count', + domainId: 'anotherDomainId', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-11T08:18:03.030Z', + }, + { + type: 'usage-counters', + id: 'anotherDomainId2:09042021:count:some_event_name', + attributes: { + count: 1, + counterName: 'some_event_name', + counterType: 'count', + domainId: 'anotherDomainId2', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-20T08:18:03.030Z', + }, + { + type: 'usage-counters', + id: 'anotherDomainId2:09042021:count:malformed_event', + attributes: { + // @ts-expect-error + count: 'malformed', + counterName: 'malformed_event', + counterType: 'count', + domainId: 'anotherDomainId2', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-20T08:18:03.030Z', + }, + { + type: 'usage-counters', + id: 'anotherDomainId2:09042021:custom_type:some_event_name', + attributes: { + count: 3, + counterName: 'some_event_name', + counterType: 'custom_type', + domainId: 'anotherDomainId2', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-20T08:18:03.030Z', + }, + { + type: 'usage-counters', + id: 'anotherDomainId3:09042021:custom_type:zero_count', + attributes: { + count: 0, + counterName: 'zero_count', + counterType: 'custom_type', + domainId: 'anotherDomainId3', + }, + references: [], + coreMigrationVersion: '8.0.0', + updated_at: '2021-04-20T08:18:03.030Z', + }, +]; diff --git a/src/plugins/kibana_usage_collection/server/collectors/usage_counters/index.ts b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/index.ts new file mode 100644 index 00000000000000..1873fae42e54ad --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { registerUsageCountersUsageCollector } from './register_usage_counters_collector'; +export { registerUsageCountersRollups } from './rollups'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/usage_counters/register_usage_counters_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/register_usage_counters_collector.test.ts new file mode 100644 index 00000000000000..945eb007fe23f9 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/register_usage_counters_collector.test.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { transformRawCounter } from './register_usage_counters_collector'; +import { rawUsageCounters } from './__fixtures__/usage_counter_saved_objects'; + +describe('transformRawCounter', () => { + it('transforms saved object raw entries', () => { + const result = rawUsageCounters.map(transformRawCounter); + expect(result).toMatchInlineSnapshot(` + Array [ + undefined, + Object { + "counterName": "some_event_name", + "counterType": "count", + "domainId": "anotherDomainId", + "fromTimestamp": "2021-04-09T00:00:00Z", + "lastUpdatedAt": "2021-04-09T08:18:03.030Z", + "total": 4, + }, + Object { + "counterName": "some_event_name", + "counterType": "count", + "domainId": "anotherDomainId", + "fromTimestamp": "2021-04-11T00:00:00Z", + "lastUpdatedAt": "2021-04-11T08:18:03.030Z", + "total": 4, + }, + Object { + "counterName": "some_event_name", + "counterType": "count", + "domainId": "anotherDomainId2", + "fromTimestamp": "2021-04-20T00:00:00Z", + "lastUpdatedAt": "2021-04-20T08:18:03.030Z", + "total": 1, + }, + undefined, + Object { + "counterName": "some_event_name", + "counterType": "custom_type", + "domainId": "anotherDomainId2", + "fromTimestamp": "2021-04-20T00:00:00Z", + "lastUpdatedAt": "2021-04-20T08:18:03.030Z", + "total": 3, + }, + undefined, + ] + `); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/usage_counters/register_usage_counters_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/register_usage_counters_collector.ts new file mode 100644 index 00000000000000..9c6db00fb35978 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/register_usage_counters_collector.ts @@ -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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import moment from 'moment'; +import { + CollectorFetchContext, + UsageCollectionSetup, + USAGE_COUNTERS_SAVED_OBJECT_TYPE, + UsageCountersSavedObject, + UsageCountersSavedObjectAttributes, +} from '../../../../usage_collection/server'; + +interface UsageCounterEvent { + domainId: string; + counterName: string; + counterType: string; + lastUpdatedAt?: string; + fromTimestamp?: string; + total: number; +} + +export interface UiCountersUsage { + dailyEvents: UsageCounterEvent[]; +} + +export function transformRawCounter( + rawUsageCounter: UsageCountersSavedObject +): UsageCounterEvent | undefined { + const { + attributes: { count, counterName, counterType, domainId }, + updated_at: lastUpdatedAt, + } = rawUsageCounter; + const fromTimestamp = moment(lastUpdatedAt).utc().startOf('day').format(); + + if (domainId === 'uiCounter' || typeof count !== 'number' || count < 1) { + return; + } + + return { + domainId, + counterName, + counterType, + lastUpdatedAt, + fromTimestamp, + total: count, + }; +} + +export function registerUsageCountersUsageCollector(usageCollection: UsageCollectionSetup) { + const collector = usageCollection.makeUsageCollector({ + type: 'usage_counters', + schema: { + dailyEvents: { + type: 'array', + items: { + domainId: { + type: 'keyword', + _meta: { description: 'Domain name of the metric (ie plugin name).' }, + }, + counterName: { + type: 'keyword', + _meta: { description: 'Name of the counter that happened.' }, + }, + lastUpdatedAt: { + type: 'date', + _meta: { description: 'Time at which the metric was last updated.' }, + }, + fromTimestamp: { + type: 'date', + _meta: { description: 'Time at which the metric was captured.' }, + }, + counterType: { + type: 'keyword', + _meta: { description: 'The type of counter used.' }, + }, + total: { + type: 'integer', + _meta: { description: 'The total number of times the event happened.' }, + }, + }, + }, + }, + fetch: async ({ soClient }: CollectorFetchContext) => { + const { + saved_objects: rawUsageCounters, + } = await soClient.find({ + type: USAGE_COUNTERS_SAVED_OBJECT_TYPE, + fields: ['count', 'counterName', 'counterType', 'domainId'], + filter: `NOT ${USAGE_COUNTERS_SAVED_OBJECT_TYPE}.attributes.domainId: uiCounter`, + perPage: 10000, + }); + + return { + dailyEvents: rawUsageCounters.reduce((acc, rawUsageCounter) => { + try { + const event = transformRawCounter(rawUsageCounter); + if (event) { + acc.push(event); + } + } catch (_) { + // swallow error; allows sending successfully transformed objects. + } + return acc; + }, [] as UsageCounterEvent[]), + }; + }, + isReady: () => true, + }); + + usageCollection.registerCollector(collector); +} diff --git a/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/constants.ts b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/constants.ts new file mode 100644 index 00000000000000..1c1ca3f466df2c --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/constants.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** + * Roll indices every 24h + */ +export const ROLL_INDICES_INTERVAL = 24 * 60 * 60 * 1000; + +/** + * Start rolling indices after 5 minutes up + */ +export const ROLL_INDICES_START = 5 * 60 * 1000; + +/** + * Number of days to keep the Usage counters saved object documents + */ +export const USAGE_COUNTERS_KEEP_DOCS_FOR_DAYS = 5; diff --git a/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/index.ts b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/index.ts new file mode 100644 index 00000000000000..bf15f4d8758602 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { registerUsageCountersRollups } from './register_rollups'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/register_rollups.ts b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/register_rollups.ts new file mode 100644 index 00000000000000..30ad993d54a8e6 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/register_rollups.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { timer } from 'rxjs'; +import { Logger, ISavedObjectsRepository } from 'kibana/server'; +import { ROLL_INDICES_INTERVAL, ROLL_INDICES_START } from './constants'; +import { rollUsageCountersIndices } from './rollups'; + +export function registerUsageCountersRollups( + logger: Logger, + getSavedObjectsClient: () => ISavedObjectsRepository | undefined +) { + timer(ROLL_INDICES_START, ROLL_INDICES_INTERVAL).subscribe(() => + rollUsageCountersIndices(logger, getSavedObjectsClient()) + ); +} diff --git a/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/rollups.test.ts b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/rollups.test.ts new file mode 100644 index 00000000000000..c6cdaae20a8bcc --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/rollups.test.ts @@ -0,0 +1,170 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import moment from 'moment'; +import { isSavedObjectOlderThan, rollUsageCountersIndices } from './rollups'; +import { savedObjectsRepositoryMock, loggingSystemMock } from '../../../../../../core/server/mocks'; +import { SavedObjectsFindResult } from '../../../../../../core/server'; + +import { + UsageCountersSavedObjectAttributes, + USAGE_COUNTERS_SAVED_OBJECT_TYPE, +} from '../../../../../usage_collection/server'; + +import { USAGE_COUNTERS_KEEP_DOCS_FOR_DAYS } from './constants'; + +const createMockSavedObjectDoc = (updatedAt: moment.Moment, id: string) => + ({ + id, + type: 'usage-counter', + attributes: { + count: 3, + counterName: 'testName', + counterType: 'count', + domainId: 'testDomain', + }, + references: [], + updated_at: updatedAt.format(), + version: 'WzI5LDFd', + score: 0, + } as SavedObjectsFindResult); + +describe('isSavedObjectOlderThan', () => { + it(`returns true if doc is older than x days`, () => { + const numberOfDays = 1; + const startDate = moment().format(); + const doc = createMockSavedObjectDoc(moment().subtract(2, 'days'), 'some-id'); + const result = isSavedObjectOlderThan({ + numberOfDays, + startDate, + doc, + }); + expect(result).toBe(true); + }); + + it(`returns false if doc is exactly x days old`, () => { + const numberOfDays = 1; + const startDate = moment().format(); + const doc = createMockSavedObjectDoc(moment().subtract(1, 'days'), 'some-id'); + const result = isSavedObjectOlderThan({ + numberOfDays, + startDate, + doc, + }); + expect(result).toBe(false); + }); + + it(`returns false if doc is younger than x days`, () => { + const numberOfDays = 2; + const startDate = moment().format(); + const doc = createMockSavedObjectDoc(moment().subtract(1, 'days'), 'some-id'); + const result = isSavedObjectOlderThan({ + numberOfDays, + startDate, + doc, + }); + expect(result).toBe(false); + }); +}); + +describe('rollUsageCountersIndices', () => { + let logger: ReturnType; + let savedObjectClient: ReturnType; + + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + savedObjectClient = savedObjectsRepositoryMock.create(); + }); + + it('returns undefined if no savedObjectsClient initialised yet', async () => { + await expect(rollUsageCountersIndices(logger, undefined)).resolves.toBe(undefined); + expect(logger.warn).toHaveBeenCalledTimes(0); + }); + + it('does not delete any documents on empty saved objects', async () => { + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case USAGE_COUNTERS_SAVED_OBJECT_TYPE: + return { saved_objects: [], total: 0, page, per_page: perPage }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + await expect(rollUsageCountersIndices(logger, savedObjectClient)).resolves.toEqual([]); + expect(savedObjectClient.find).toBeCalled(); + expect(savedObjectClient.delete).not.toBeCalled(); + expect(logger.warn).toHaveBeenCalledTimes(0); + }); + + it(`deletes documents older than ${USAGE_COUNTERS_KEEP_DOCS_FOR_DAYS} days`, async () => { + const mockSavedObjects = [ + createMockSavedObjectDoc(moment().subtract(5, 'days'), 'doc-id-1'), + createMockSavedObjectDoc(moment().subtract(9, 'days'), 'doc-id-1'), + createMockSavedObjectDoc(moment().subtract(1, 'days'), 'doc-id-2'), + createMockSavedObjectDoc(moment().subtract(6, 'days'), 'doc-id-3'), + ]; + + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case USAGE_COUNTERS_SAVED_OBJECT_TYPE: + return { saved_objects: mockSavedObjects, total: 0, page, per_page: perPage }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + await expect(rollUsageCountersIndices(logger, savedObjectClient)).resolves.toHaveLength(2); + expect(savedObjectClient.find).toBeCalled(); + expect(savedObjectClient.delete).toHaveBeenCalledTimes(2); + expect(savedObjectClient.delete).toHaveBeenNthCalledWith( + 1, + USAGE_COUNTERS_SAVED_OBJECT_TYPE, + 'doc-id-1' + ); + expect(savedObjectClient.delete).toHaveBeenNthCalledWith( + 2, + USAGE_COUNTERS_SAVED_OBJECT_TYPE, + 'doc-id-3' + ); + expect(logger.warn).toHaveBeenCalledTimes(0); + }); + + it(`logs warnings on savedObject.find failure`, async () => { + savedObjectClient.find.mockImplementation(async () => { + throw new Error(`Expected error!`); + }); + await expect(rollUsageCountersIndices(logger, savedObjectClient)).resolves.toEqual(undefined); + expect(savedObjectClient.find).toBeCalled(); + expect(savedObjectClient.delete).not.toBeCalled(); + expect(logger.warn).toHaveBeenCalledTimes(2); + }); + + it(`logs warnings on savedObject.delete failure`, async () => { + const mockSavedObjects = [createMockSavedObjectDoc(moment().subtract(7, 'days'), 'doc-id-1')]; + + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case USAGE_COUNTERS_SAVED_OBJECT_TYPE: + return { saved_objects: mockSavedObjects, total: 0, page, per_page: perPage }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + savedObjectClient.delete.mockImplementation(async () => { + throw new Error(`Expected error!`); + }); + await expect(rollUsageCountersIndices(logger, savedObjectClient)).resolves.toEqual(undefined); + expect(savedObjectClient.find).toBeCalled(); + expect(savedObjectClient.delete).toHaveBeenCalledTimes(1); + expect(savedObjectClient.delete).toHaveBeenNthCalledWith( + 1, + USAGE_COUNTERS_SAVED_OBJECT_TYPE, + 'doc-id-1' + ); + expect(logger.warn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/rollups.ts b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/rollups.ts new file mode 100644 index 00000000000000..c07ea37536f2d2 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/usage_counters/rollups/rollups.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ISavedObjectsRepository, Logger } from 'kibana/server'; +import moment from 'moment'; + +import { USAGE_COUNTERS_KEEP_DOCS_FOR_DAYS } from './constants'; + +import { + UsageCountersSavedObject, + USAGE_COUNTERS_SAVED_OBJECT_TYPE, +} from '../../../../../usage_collection/server'; + +export function isSavedObjectOlderThan({ + numberOfDays, + startDate, + doc, +}: { + numberOfDays: number; + startDate: moment.Moment | string | number; + doc: Pick; +}): boolean { + const { updated_at: updatedAt } = doc; + const today = moment(startDate).startOf('day'); + const updateDay = moment(updatedAt).startOf('day'); + + const diffInDays = today.diff(updateDay, 'days'); + if (diffInDays > numberOfDays) { + return true; + } + + return false; +} + +export async function rollUsageCountersIndices( + logger: Logger, + savedObjectsClient?: ISavedObjectsRepository +) { + if (!savedObjectsClient) { + return; + } + + const now = moment(); + + try { + const { + saved_objects: rawUiCounterDocs, + } = await savedObjectsClient.find({ + type: USAGE_COUNTERS_SAVED_OBJECT_TYPE, + perPage: 1000, // Process 1000 at a time as a compromise of speed and overload + }); + + const docsToDelete = rawUiCounterDocs.filter((doc) => + isSavedObjectOlderThan({ + numberOfDays: USAGE_COUNTERS_KEEP_DOCS_FOR_DAYS, + startDate: now, + doc, + }) + ); + + return await Promise.all( + docsToDelete.map(({ id }) => savedObjectsClient.delete(USAGE_COUNTERS_SAVED_OBJECT_TYPE, id)) + ); + } catch (err) { + logger.warn(`Failed to rollup Usage Counters saved objects.`); + logger.warn(err); + } +} diff --git a/src/plugins/kibana_usage_collection/server/index.test.mocks.ts b/src/plugins/kibana_usage_collection/server/mocks.ts similarity index 100% rename from src/plugins/kibana_usage_collection/server/index.test.mocks.ts rename to src/plugins/kibana_usage_collection/server/mocks.ts diff --git a/src/plugins/kibana_usage_collection/server/index.test.ts b/src/plugins/kibana_usage_collection/server/plugin.test.ts similarity index 59% rename from src/plugins/kibana_usage_collection/server/index.test.ts rename to src/plugins/kibana_usage_collection/server/plugin.test.ts index b4c52f8353d791..86204ed30e6563 100644 --- a/src/plugins/kibana_usage_collection/server/index.test.ts +++ b/src/plugins/kibana_usage_collection/server/plugin.test.ts @@ -14,8 +14,8 @@ import { import { CollectorOptions, createUsageCollectionSetupMock, -} from '../../usage_collection/server/usage_collection.mock'; -import { cloudDetailsMock } from './index.test.mocks'; +} from '../../usage_collection/server/mocks'; +import { cloudDetailsMock } from './mocks'; import { plugin } from './'; @@ -38,13 +38,67 @@ describe('kibana_usage_collection', () => { cloudDetailsMock.mockClear(); }); - test('Runs the setup method without issues', () => { + test('Runs the setup method without issues', async () => { const coreSetup = coreMock.createSetup(); expect(pluginInstance.setup(coreSetup, { usageCollection })).toBe(undefined); - usageCollectors.forEach(({ isReady }) => { - expect(isReady()).toMatchSnapshot(); // Some should return false at this stage - }); + + await expect( + Promise.all( + usageCollectors.map(async (usageCollector) => { + const isReady = await usageCollector.isReady(); + const type = usageCollector.type; + return { type, isReady }; + }) + ) + ).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "isReady": true, + "type": "ui_counters", + }, + Object { + "isReady": true, + "type": "usage_counters", + }, + Object { + "isReady": false, + "type": "kibana_stats", + }, + Object { + "isReady": true, + "type": "kibana", + }, + Object { + "isReady": false, + "type": "stack_management", + }, + Object { + "isReady": false, + "type": "ui_metric", + }, + Object { + "isReady": false, + "type": "application_usage", + }, + Object { + "isReady": false, + "type": "cloud_provider", + }, + Object { + "isReady": true, + "type": "csp", + }, + Object { + "isReady": false, + "type": "core", + }, + Object { + "isReady": true, + "type": "localization", + }, + ] + `); }); test('Runs the start method without issues', () => { diff --git a/src/plugins/kibana_usage_collection/server/plugin.ts b/src/plugins/kibana_usage_collection/server/plugin.ts index 74d2d281ff8f64..a27b8dff57b67c 100644 --- a/src/plugins/kibana_usage_collection/server/plugin.ts +++ b/src/plugins/kibana_usage_collection/server/plugin.ts @@ -35,6 +35,8 @@ import { registerUiCountersUsageCollector, registerUiCounterSavedObjectType, registerUiCountersRollups, + registerUsageCountersRollups, + registerUsageCountersUsageCollector, } from './collectors'; interface KibanaUsageCollectionPluginsDepsSetup { @@ -50,18 +52,23 @@ export class KibanaUsageCollectionPlugin implements Plugin { private uiSettingsClient?: IUiSettingsClient; private metric$: Subject; private coreUsageData?: CoreUsageDataStart; + private stopUsingUiCounterIndicies$: Subject; constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); this.legacyConfig$ = initializerContext.config.legacy.globalConfig$; this.metric$ = new Subject(); + this.stopUsingUiCounterIndicies$ = new Subject(); } public setup(coreSetup: CoreSetup, { usageCollection }: KibanaUsageCollectionPluginsDepsSetup) { + usageCollection.createUsageCounter('uiCounters'); + this.registerUsageCollectors( usageCollection, coreSetup, this.metric$, + this.stopUsingUiCounterIndicies$, coreSetup.savedObjects.registerType.bind(coreSetup.savedObjects) ); } @@ -77,12 +84,14 @@ export class KibanaUsageCollectionPlugin implements Plugin { public stop() { this.metric$.complete(); + this.stopUsingUiCounterIndicies$.complete(); } private registerUsageCollectors( usageCollection: UsageCollectionSetup, coreSetup: CoreSetup, metric$: Subject, + stopUsingUiCounterIndicies$: Subject, registerType: SavedObjectsRegisterType ) { const getSavedObjectsClient = () => this.savedObjectsClient; @@ -90,8 +99,15 @@ export class KibanaUsageCollectionPlugin implements Plugin { const getCoreUsageDataService = () => this.coreUsageData!; registerUiCounterSavedObjectType(coreSetup.savedObjects); - registerUiCountersRollups(this.logger.get('ui-counters'), getSavedObjectsClient); - registerUiCountersUsageCollector(usageCollection); + registerUiCountersRollups( + this.logger.get('ui-counters'), + stopUsingUiCounterIndicies$, + getSavedObjectsClient + ); + registerUiCountersUsageCollector(usageCollection, stopUsingUiCounterIndicies$); + + registerUsageCountersRollups(this.logger.get('usage-counters-rollup'), getSavedObjectsClient); + registerUsageCountersUsageCollector(usageCollection); registerOpsStatsCollector(usageCollection, metric$); registerKibanaUsageCollector(usageCollection, this.legacyConfig$); diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 41b75824e992d5..56b7d98deaef89 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -9308,6 +9308,53 @@ } } }, + "usage_counters": { + "properties": { + "dailyEvents": { + "type": "array", + "items": { + "properties": { + "domainId": { + "type": "keyword", + "_meta": { + "description": "Domain name of the metric (ie plugin name)." + } + }, + "counterName": { + "type": "keyword", + "_meta": { + "description": "Name of the counter that happened." + } + }, + "lastUpdatedAt": { + "type": "date", + "_meta": { + "description": "Time at which the metric was last updated." + } + }, + "fromTimestamp": { + "type": "date", + "_meta": { + "description": "Time at which the metric was captured." + } + }, + "counterType": { + "type": "keyword", + "_meta": { + "description": "The type of counter used." + } + }, + "total": { + "type": "integer", + "_meta": { + "description": "The total number of times the event happened." + } + } + } + } + } + } + }, "telemetry": { "properties": { "opt_in_status": { diff --git a/src/plugins/usage_collection/README.mdx b/src/plugins/usage_collection/README.mdx index 04e1e0fbb50065..a6f6f6c8e59716 100644 --- a/src/plugins/usage_collection/README.mdx +++ b/src/plugins/usage_collection/README.mdx @@ -20,6 +20,7 @@ The way to report the usage of any feature depends on whether the actions to tra In any case, to use any of these APIs, the plugin must optionally require the plugin `usageCollection`: + ```json // plugin/kibana.json { @@ -112,6 +113,100 @@ Not an API as such. However, Data Telemetry collects the usage of known patterns This collector does not report the name of the indices nor any content. It only provides stats about usage of known shippers/ingest tools. +#### Usage Counters + +Usage counters allows plugins to report user triggered events from the server. This api has feature parity with UI Counters on the `public` plugin side of usage_collection. + +Usage counters provide instrumentation on the server to count triggered events such as "api called", "threshold reached", and miscellaneous events count. + +It is useful for gathering _semi-aggregated_ events with a per day granularity. +This allows tracking trends in usage and provides enough granularity for this type of telemetry to provide insights such as +- "How many times this threshold has been reached?" +- "What is the trend in usage of this api?" +- "How frequent are users hitting this error per day?" +- "What is the success rate of this operation?" +- "Which option is being selected the most/least?" + +##### How to use it + +To create a usage counter for your plugin, use the API `usageCollection.createUsageCounter` as follows: + +```ts +// server/plugin.ts +import type { Plugin, CoreStart } from '../../../core/server'; +import type { UsageCollectionSetup, UsageCounter } from '../../../plugins/usage_collection/server'; + +export class MyPlugin implements Plugin { + private usageCounter?: UsageCounter; + public setup( + core: CoreStart, + { usageCollection }: { usageCollection?: UsageCollectionSetup } + ) { + + /** + * Create a usage counter for this plugin. Domain ID must be unique. + * It is advised to use the plugin name as the domain ID for most cases. + */ + this.usageCounter = usageCollection?.createUsageCounter(''); + try { + doSomeOperation(); + this.usageCounter?.incrementCounter({ + counterName: 'doSomeOperation_success', + incrementBy: 1, + }); + } catch (err) { + this.usageCounter?.incrementCounter({ + counterName: 'doSomeOperation_error', + counterType: 'error', + incrementBy: 1, + }); + logger.error(err); + } + } +} +``` + +Pass the created `usageCounter` around in your service to instrument usage. + +That's all you need to do! The Usage counters service will handle piping these counters all the way to the telemetry service. + +##### Telemetry reported usage + +Usage counters are reported inside the telemetry usage payload under `stack_stats.kibana.plugins.usage_counters`. + +```ts +{ + usage_counters: { + dailyEvents: [ + { + domainId: '', + counterName: 'doSomeOperation_success', + counterType: 'count', + lastUpdatedAt: '2021-11-20T11:43:00.961Z', + fromTimestamp: '2021-11-20T00:00:00Z', + total: 3, + }, + { + domainId: '', + counterName: 'doSomeOperation_success', + counterType: 'count', + lastUpdatedAt: '2021-11-21T10:30:00.961Z', + fromTimestamp: '2021-11-21T00:00:00Z', + total: 5, + }, + { + domainId: '', + counterName: 'doSomeOperation_error', + counterType: 'error', + lastUpdatedAt: '2021-11-20T11:43:00.961Z', + fromTimestamp: '2021-11-20T00:00:00Z', + total: 1, + }, + ], + }, +} +``` + #### Custom collector In many cases, plugins need to report the custom usage of a feature. In this cases, the plugins must complete the following 2 steps in the `setup` lifecycle step: diff --git a/src/plugins/usage_collection/common/ui_counters.ts b/src/plugins/usage_collection/common/ui_counters.ts new file mode 100644 index 00000000000000..3ed6e44aee4191 --- /dev/null +++ b/src/plugins/usage_collection/common/ui_counters.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const serializeUiCounterName = ({ + appName, + eventName, +}: { + appName: string; + eventName: string; +}) => { + return `${appName}:${eventName}`; +}; + +export const deserializeUiCounterName = (key: string) => { + const [appName, ...restKey] = key.split(':'); + const eventName = restKey.join(':'); + return { appName, eventName }; +}; diff --git a/src/plugins/usage_collection/server/collector/collector_set.ts b/src/plugins/usage_collection/server/collector/collector_set.ts index 32a58a6657eec2..4de5691eaaa705 100644 --- a/src/plugins/usage_collection/server/collector/collector_set.ts +++ b/src/plugins/usage_collection/server/collector/collector_set.ts @@ -25,22 +25,6 @@ interface CollectorSetConfig { collectors?: AnyCollector[]; } -/** - * Public interface of the CollectorSet (makes it easier to mock only the public methods) - */ -export type CollectorSetPublic = Pick< - CollectorSet, - | 'makeStatsCollector' - | 'makeUsageCollector' - | 'registerCollector' - | 'getCollectorByType' - | 'areAllCollectorsReady' - | 'bulkFetch' - | 'bulkFetchUsage' - | 'toObject' - | 'toApiFieldNames' ->; - export class CollectorSet { private _waitingForAllCollectorsTimestamp?: number; private readonly logger: Logger; @@ -215,19 +199,19 @@ export class CollectorSet { * Convert an array of fetched stats results into key/object * @param statsData Array of fetched stats results */ - public toObject, T = unknown>( + public toObject = , T = unknown>( statsData: Array<{ type: string; result: T }> = [] - ): Result { + ): Result => { return Object.fromEntries(statsData.map(({ type, result }) => [type, result])) as Result; - } + }; /** * Rename fields to use API conventions * @param apiData Data to be normalized */ - public toApiFieldNames( + public toApiFieldNames = ( apiData: Record | unknown[] - ): Record | unknown[] { + ): Record | unknown[] => { // handle array and return early, or return a reduced object if (Array.isArray(apiData)) { return apiData.map((value) => this.getValueOrRecurse(value)); @@ -244,14 +228,14 @@ export class CollectorSet { return [newName, this.getValueOrRecurse(value)]; }) ); - } + }; - private getValueOrRecurse(value: unknown) { + private getValueOrRecurse = (value: unknown) => { if (Array.isArray(value) || (typeof value === 'object' && value !== null)) { return this.toApiFieldNames(value as Record | unknown[]); // recurse } return value; - } + }; private makeCollectorSetFromArray = (collectors: AnyCollector[]) => { return new CollectorSet({ diff --git a/src/plugins/usage_collection/server/collector/index.ts b/src/plugins/usage_collection/server/collector/index.ts index d5e0d95659e58d..594455f70fdf8d 100644 --- a/src/plugins/usage_collection/server/collector/index.ts +++ b/src/plugins/usage_collection/server/collector/index.ts @@ -7,7 +7,6 @@ */ export { CollectorSet } from './collector_set'; -export type { CollectorSetPublic } from './collector_set'; export { Collector } from './collector'; export type { AllowedSchemaTypes, diff --git a/src/plugins/usage_collection/server/config.ts b/src/plugins/usage_collection/server/config.ts index ff6ea8424ba616..cd6f6b9d81396f 100644 --- a/src/plugins/usage_collection/server/config.ts +++ b/src/plugins/usage_collection/server/config.ts @@ -11,6 +11,11 @@ import { PluginConfigDescriptor } from 'src/core/server'; import { DEFAULT_MAXIMUM_WAIT_TIME_FOR_ALL_COLLECTORS_IN_S } from '../common/constants'; export const configSchema = schema.object({ + usageCounters: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + retryCount: schema.number({ defaultValue: 1 }), + bufferDuration: schema.duration({ defaultValue: '5s' }), + }), uiCounters: schema.object({ enabled: schema.boolean({ defaultValue: true }), debug: schema.boolean({ defaultValue: schema.contextRef('dev') }), diff --git a/src/plugins/usage_collection/server/index.ts b/src/plugins/usage_collection/server/index.ts index dd9e6644a827dc..b5441a8b7b34d0 100644 --- a/src/plugins/usage_collection/server/index.ts +++ b/src/plugins/usage_collection/server/index.ts @@ -18,6 +18,19 @@ export type { UsageCollectorOptions, CollectorFetchContext, } from './collector'; + +export type { + UsageCountersSavedObject, + UsageCountersSavedObjectAttributes, + IncrementCounterParams, +} from './usage_counters'; + +export { + USAGE_COUNTERS_SAVED_OBJECT_TYPE, + serializeCounterKey, + UsageCounter, +} from './usage_counters'; + export type { UsageCollectionSetup } from './plugin'; export { config } from './config'; export const plugin = (initializerContext: PluginInitializerContext) => diff --git a/src/plugins/usage_collection/server/mocks.ts b/src/plugins/usage_collection/server/mocks.ts index e5ad1022636262..b84fa0f0aab70d 100644 --- a/src/plugins/usage_collection/server/mocks.ts +++ b/src/plugins/usage_collection/server/mocks.ts @@ -6,20 +6,61 @@ * Side Public License, v 1. */ -import { loggingSystemMock } from '../../../core/server/mocks'; -import { UsageCollectionSetup } from './plugin'; -import { CollectorSet } from './collector'; -export { Collector, createCollectorFetchContextMock } from './usage_collection.mock'; - -const createSetupContract = () => { - return { - ...new CollectorSet({ - logger: loggingSystemMock.createLogger(), - maximumWaitTimeForAllCollectorsInS: 1, - }), - } as UsageCollectionSetup; +import { + elasticsearchServiceMock, + httpServerMock, + loggingSystemMock, + savedObjectsClientMock, +} from '../../../../src/core/server/mocks'; + +import { CollectorOptions, Collector, CollectorSet } from './collector'; +import { UsageCollectionSetup, CollectorFetchContext } from './index'; + +export type { CollectorOptions }; +export { Collector }; + +export const createUsageCollectionSetupMock = () => { + const collectorSet = new CollectorSet({ + logger: loggingSystemMock.createLogger(), + maximumWaitTimeForAllCollectorsInS: 1, + }); + + const usageCollectionSetupMock: jest.Mocked = { + createUsageCounter: jest.fn(), + getUsageCounterByType: jest.fn(), + areAllCollectorsReady: jest.fn().mockImplementation(collectorSet.areAllCollectorsReady), + bulkFetch: jest.fn().mockImplementation(collectorSet.bulkFetch), + getCollectorByType: jest.fn().mockImplementation(collectorSet.getCollectorByType), + toApiFieldNames: jest.fn().mockImplementation(collectorSet.toApiFieldNames), + toObject: jest.fn().mockImplementation(collectorSet.toObject), + makeStatsCollector: jest.fn().mockImplementation(collectorSet.makeStatsCollector), + makeUsageCollector: jest.fn().mockImplementation(collectorSet.makeUsageCollector), + registerCollector: jest.fn().mockImplementation(collectorSet.registerCollector), + }; + + usageCollectionSetupMock.areAllCollectorsReady.mockResolvedValue(true); + return usageCollectionSetupMock; }; +export function createCollectorFetchContextMock(): jest.Mocked> { + const collectorFetchClientsMock: jest.Mocked> = { + esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, + soClient: savedObjectsClientMock.create(), + }; + return collectorFetchClientsMock; +} + +export function createCollectorFetchContextWithKibanaMock(): jest.Mocked< + CollectorFetchContext +> { + const collectorFetchClientsMock: jest.Mocked> = { + esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, + soClient: savedObjectsClientMock.create(), + kibanaRequest: httpServerMock.createKibanaRequest(), + }; + return collectorFetchClientsMock; +} + export const usageCollectionPluginMock = { - createSetupContract, + createSetupContract: createUsageCollectionSetupMock, }; diff --git a/src/plugins/usage_collection/server/plugin.ts b/src/plugins/usage_collection/server/plugin.ts index a44365ae9be9a9..37d7327aed662b 100644 --- a/src/plugins/usage_collection/server/plugin.ts +++ b/src/plugins/usage_collection/server/plugin.ts @@ -15,30 +15,78 @@ import { Plugin, } from 'src/core/server'; import { ConfigType } from './config'; -import { CollectorSet, CollectorSetPublic } from './collector'; +import { CollectorSet } from './collector'; import { setupRoutes } from './routes'; -export type UsageCollectionSetup = CollectorSetPublic; -export class UsageCollectionPlugin implements Plugin { +import { UsageCountersService } from './usage_counters'; +import type { UsageCountersServiceSetup } from './usage_counters'; + +export interface UsageCollectionSetup { + /** + * Creates and registers a usage counter to collect daily aggregated plugin counter events + */ + createUsageCounter: UsageCountersServiceSetup['createUsageCounter']; + /** + * Returns a usage counter by type + */ + getUsageCounterByType: UsageCountersServiceSetup['getUsageCounterByType']; + /** + * Creates a usage collector to collect plugin telemetry data. + * registerCollector must be called to connect the created collecter with the service. + */ + makeUsageCollector: CollectorSet['makeUsageCollector']; + /** + * Register a usage collector or a stats collector. + * Used to connect the created collector to telemetry. + */ + registerCollector: CollectorSet['registerCollector']; + /** + * Returns a usage collector by type + */ + getCollectorByType: CollectorSet['getCollectorByType']; + /* internal: telemetry use */ + areAllCollectorsReady: CollectorSet['areAllCollectorsReady']; + /* internal: telemetry use */ + bulkFetch: CollectorSet['bulkFetch']; + /* internal: telemetry use */ + toObject: CollectorSet['toObject']; + /* internal: monitoring use */ + toApiFieldNames: CollectorSet['toApiFieldNames']; + /* internal: telemtery and monitoring use */ + makeStatsCollector: CollectorSet['makeStatsCollector']; +} + +export class UsageCollectionPlugin implements Plugin { private readonly logger: Logger; private savedObjects?: ISavedObjectsRepository; + private usageCountersService?: UsageCountersService; + constructor(private readonly initializerContext: PluginInitializerContext) { this.logger = this.initializerContext.logger.get(); } - public setup(core: CoreSetup) { + public setup(core: CoreSetup): UsageCollectionSetup { const config = this.initializerContext.config.get(); const collectorSet = new CollectorSet({ - logger: this.logger.get('collector-set'), + logger: this.logger.get('usage-collection', 'collector-set'), maximumWaitTimeForAllCollectorsInS: config.maximumWaitTimeForAllCollectorsInS, }); - const globalConfig = this.initializerContext.config.legacy.get(); + this.usageCountersService = new UsageCountersService({ + logger: this.logger.get('usage-collection', 'usage-counters-service'), + retryCount: config.usageCounters.retryCount, + bufferDurationMs: config.usageCounters.bufferDuration.asMilliseconds(), + }); + + const { createUsageCounter, getUsageCounterByType } = this.usageCountersService.setup(core); + const uiCountersUsageCounter = createUsageCounter('uiCounter'); + const globalConfig = this.initializerContext.config.legacy.get(); const router = core.http.createRouter(); setupRoutes({ router, + uiCountersUsageCounter, getSavedObjects: () => this.savedObjects, collectorSet, config: { @@ -52,15 +100,38 @@ export class UsageCollectionPlugin implements Plugin { overallStatus$: core.status.overall$, }); - return collectorSet; + return { + areAllCollectorsReady: collectorSet.areAllCollectorsReady, + bulkFetch: collectorSet.bulkFetch, + getCollectorByType: collectorSet.getCollectorByType, + makeStatsCollector: collectorSet.makeStatsCollector, + makeUsageCollector: collectorSet.makeUsageCollector, + registerCollector: collectorSet.registerCollector, + toApiFieldNames: collectorSet.toApiFieldNames, + toObject: collectorSet.toObject, + createUsageCounter, + getUsageCounterByType, + }; } public start({ savedObjects }: CoreStart) { this.logger.debug('Starting plugin'); + const config = this.initializerContext.config.get(); + if (!this.usageCountersService) { + throw new Error('plugin setup must be called first.'); + } + this.savedObjects = savedObjects.createInternalRepository(); + if (config.usageCounters.enabled) { + this.usageCountersService.start({ savedObjects }); + } else { + // call stop() to complete observers. + this.usageCountersService.stop(); + } } public stop() { this.logger.debug('Stopping plugin'); + this.usageCountersService?.stop(); } } diff --git a/src/plugins/usage_collection/server/report/store_report.test.ts b/src/plugins/usage_collection/server/report/store_report.test.ts index dfcdd1f8e7e42c..08fdec4ae804f9 100644 --- a/src/plugins/usage_collection/server/report/store_report.test.ts +++ b/src/plugins/usage_collection/server/report/store_report.test.ts @@ -12,11 +12,11 @@ import { savedObjectsRepositoryMock } from '../../../../core/server/mocks'; import { storeReport } from './store_report'; import { ReportSchemaType } from './schema'; import { METRIC_TYPE } from '@kbn/analytics'; -import moment from 'moment'; +import { usageCountersServiceMock } from '../usage_counters/usage_counters_service.mock'; describe('store_report', () => { - const momentTimestamp = moment(); - const date = momentTimestamp.format('DDMMYYYY'); + const usageCountersServiceSetup = usageCountersServiceMock.createSetupContract(); + const uiCountersUsageCounter = usageCountersServiceSetup.createUsageCounter('uiCounter'); let repository: ReturnType; @@ -64,34 +64,56 @@ describe('store_report', () => { }, }, }; - await storeReport(repository, report); + await storeReport(repository, uiCountersUsageCounter, report); - expect(repository.create).toHaveBeenCalledWith( - 'ui-metric', - { count: 1 }, - { - id: 'key-user-agent:test-user-agent', - overwrite: true, - } - ); - expect(repository.incrementCounter).toHaveBeenNthCalledWith( - 1, - 'ui-metric', - 'test-app-name:test-event-name', - [{ fieldName: 'count', incrementBy: 3 }] - ); - expect(repository.incrementCounter).toHaveBeenNthCalledWith( - 2, - 'ui-counter', - `test-app-name:${date}:${METRIC_TYPE.LOADED}:test-event-name`, - [{ fieldName: 'count', incrementBy: 1 }] - ); - expect(repository.incrementCounter).toHaveBeenNthCalledWith( - 3, - 'ui-counter', - `test-app-name:${date}:${METRIC_TYPE.CLICK}:test-event-name`, - [{ fieldName: 'count', incrementBy: 2 }] - ); + expect(repository.create.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "ui-metric", + Object { + "count": 1, + }, + Object { + "id": "key-user-agent:test-user-agent", + "overwrite": true, + }, + ], + ] + `); + + expect(repository.incrementCounter.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "ui-metric", + "test-app-name:test-event-name", + Array [ + Object { + "fieldName": "count", + "incrementBy": 3, + }, + ], + ], + ] + `); + expect((uiCountersUsageCounter.incrementCounter as jest.Mock).mock.calls) + .toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "counterName": "test-app-name:test-event-name", + "counterType": "loaded", + "incrementBy": 1, + }, + ], + Array [ + Object { + "counterName": "test-app-name:test-event-name", + "counterType": "click", + "incrementBy": 2, + }, + ], + ] + `); expect(storeApplicationUsageMock).toHaveBeenCalledTimes(1); expect(storeApplicationUsageMock).toHaveBeenCalledWith( @@ -108,7 +130,7 @@ describe('store_report', () => { uiCounter: void 0, application_usage: void 0, }; - await storeReport(repository, report); + await storeReport(repository, uiCountersUsageCounter, report); expect(repository.bulkCreate).not.toHaveBeenCalled(); expect(repository.incrementCounter).not.toHaveBeenCalled(); diff --git a/src/plugins/usage_collection/server/report/store_report.ts b/src/plugins/usage_collection/server/report/store_report.ts index 0545a54792d450..1647fb8893be1c 100644 --- a/src/plugins/usage_collection/server/report/store_report.ts +++ b/src/plugins/usage_collection/server/report/store_report.ts @@ -11,9 +11,12 @@ import moment from 'moment'; import { chain, sumBy } from 'lodash'; import { ReportSchemaType } from './schema'; import { storeApplicationUsage } from './store_application_usage'; +import { UsageCounter } from '../usage_counters'; +import { serializeUiCounterName } from '../../common/ui_counters'; export async function storeReport( internalRepository: ISavedObjectsRepository, + uiCountersUsageCounter: UsageCounter, report: ReportSchemaType ) { const uiCounters = report.uiCounter ? Object.entries(report.uiCounter) : []; @@ -21,7 +24,6 @@ export async function storeReport( const appUsages = report.application_usage ? Object.values(report.application_usage) : []; const momentTimestamp = moment(); - const date = momentTimestamp.format('DDMMYYYY'); const timestamp = momentTimestamp.toDate(); return Promise.allSettled([ @@ -55,14 +57,14 @@ export async function storeReport( }) .value(), // UI Counters - ...uiCounters.map(async ([key, metric]) => { + ...uiCounters.map(async ([, metric]) => { const { appName, eventName, total, type } = metric; - const savedObjectId = `${appName}:${date}:${type}:${eventName}`; - return [ - await internalRepository.incrementCounter('ui-counter', savedObjectId, [ - { fieldName: 'count', incrementBy: total }, - ]), - ]; + const counterName = serializeUiCounterName({ appName, eventName }); + uiCountersUsageCounter.incrementCounter({ + counterName, + counterType: type, + incrementBy: total, + }); }), // Application Usage storeApplicationUsage(internalRepository, appUsages, timestamp), diff --git a/src/plugins/usage_collection/server/routes/index.ts b/src/plugins/usage_collection/server/routes/index.ts index 0e17ebcbfd6953..20949224c0f6dd 100644 --- a/src/plugins/usage_collection/server/routes/index.ts +++ b/src/plugins/usage_collection/server/routes/index.ts @@ -16,14 +16,16 @@ import { Observable } from 'rxjs'; import { CollectorSet } from '../collector'; import { registerUiCountersRoute } from './ui_counters'; import { registerStatsRoute } from './stats'; - +import type { UsageCounter } from '../usage_counters'; export function setupRoutes({ router, + uiCountersUsageCounter, getSavedObjects, ...rest }: { router: IRouter; getSavedObjects: () => ISavedObjectsRepository | undefined; + uiCountersUsageCounter: UsageCounter; config: { allowAnonymous: boolean; kibanaIndex: string; @@ -39,6 +41,6 @@ export function setupRoutes({ metrics: MetricsServiceSetup; overallStatus$: Observable; }) { - registerUiCountersRoute(router, getSavedObjects); + registerUiCountersRoute(router, getSavedObjects, uiCountersUsageCounter); registerStatsRoute({ router, ...rest }); } diff --git a/src/plugins/usage_collection/server/routes/ui_counters.ts b/src/plugins/usage_collection/server/routes/ui_counters.ts index 07983ba1d65ca3..c03541b1032b67 100644 --- a/src/plugins/usage_collection/server/routes/ui_counters.ts +++ b/src/plugins/usage_collection/server/routes/ui_counters.ts @@ -9,10 +9,12 @@ import { schema } from '@kbn/config-schema'; import { IRouter, ISavedObjectsRepository } from 'src/core/server'; import { storeReport, reportSchema } from '../report'; +import { UsageCounter } from '../usage_counters'; export function registerUiCountersRoute( router: IRouter, - getSavedObjects: () => ISavedObjectsRepository | undefined + getSavedObjects: () => ISavedObjectsRepository | undefined, + uiCountersUsageCounter: UsageCounter ) { router.post( { @@ -30,7 +32,7 @@ export function registerUiCountersRoute( if (!internalRepository) { throw Error(`The saved objects client hasn't been initialised yet`); } - await storeReport(internalRepository, report); + await storeReport(internalRepository, uiCountersUsageCounter, report); return res.ok({ body: { status: 'ok' } }); } catch (error) { return res.ok({ body: { status: 'fail' } }); diff --git a/src/plugins/usage_collection/server/usage_collection.mock.ts b/src/plugins/usage_collection/server/usage_collection.mock.ts deleted file mode 100644 index 7e3f4273bbea8d..00000000000000 --- a/src/plugins/usage_collection/server/usage_collection.mock.ts +++ /dev/null @@ -1,58 +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 - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { - elasticsearchServiceMock, - httpServerMock, - loggingSystemMock, - savedObjectsClientMock, -} from '../../../../src/core/server/mocks'; - -import { CollectorOptions, Collector, UsageCollector } from './collector'; -import { UsageCollectionSetup, CollectorFetchContext } from './index'; - -export type { CollectorOptions }; -export { Collector }; - -const logger = loggingSystemMock.createLogger(); - -export const createUsageCollectionSetupMock = () => { - const usageCollectionSetupMock: jest.Mocked = { - areAllCollectorsReady: jest.fn(), - bulkFetch: jest.fn(), - bulkFetchUsage: jest.fn(), - getCollectorByType: jest.fn(), - toApiFieldNames: jest.fn(), - toObject: jest.fn(), - makeStatsCollector: jest.fn().mockImplementation((cfg) => new Collector(logger, cfg)), - makeUsageCollector: jest.fn().mockImplementation((cfg) => new UsageCollector(logger, cfg)), - registerCollector: jest.fn(), - }; - - usageCollectionSetupMock.areAllCollectorsReady.mockResolvedValue(true); - return usageCollectionSetupMock; -}; - -export function createCollectorFetchContextMock(): jest.Mocked> { - const collectorFetchClientsMock: jest.Mocked> = { - esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, - soClient: savedObjectsClientMock.create(), - }; - return collectorFetchClientsMock; -} - -export function createCollectorFetchContextWithKibanaMock(): jest.Mocked< - CollectorFetchContext -> { - const collectorFetchClientsMock: jest.Mocked> = { - esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, - soClient: savedObjectsClientMock.create(), - kibanaRequest: httpServerMock.createKibanaRequest(), - }; - return collectorFetchClientsMock; -} diff --git a/src/plugins/usage_collection/server/usage_counters/index.ts b/src/plugins/usage_collection/server/usage_counters/index.ts new file mode 100644 index 00000000000000..dc1d1f5b43edfb --- /dev/null +++ b/src/plugins/usage_collection/server/usage_counters/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type { UsageCountersServiceSetup } from './usage_counters_service'; +export type { UsageCountersSavedObjectAttributes, UsageCountersSavedObject } from './saved_objects'; +export type { IncrementCounterParams } from './usage_counter'; + +export { UsageCountersService } from './usage_counters_service'; +export { UsageCounter } from './usage_counter'; +export { USAGE_COUNTERS_SAVED_OBJECT_TYPE, serializeCounterKey } from './saved_objects'; diff --git a/src/plugins/usage_collection/server/usage_counters/saved_objects.test.ts b/src/plugins/usage_collection/server/usage_counters/saved_objects.test.ts new file mode 100644 index 00000000000000..f857d449312e63 --- /dev/null +++ b/src/plugins/usage_collection/server/usage_counters/saved_objects.test.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { serializeCounterKey, storeCounter } from './saved_objects'; +import { savedObjectsRepositoryMock } from '../../../../core/server/mocks'; +import { CounterMetric } from './usage_counter'; +import moment from 'moment'; + +describe('counterKey', () => { + test('#serializeCounterKey returns a serialized string', () => { + const result = serializeCounterKey({ + domainId: 'a', + counterName: 'b', + counterType: 'c', + date: moment('09042021', 'DDMMYYYY'), + }); + + expect(result).toMatchInlineSnapshot(`"a:09042021:c:b"`); + }); +}); + +describe('storeCounter', () => { + const internalRepository = savedObjectsRepositoryMock.create(); + + const mockNow = 1617954426939; + + beforeEach(() => { + jest.spyOn(moment, 'now').mockReturnValue(mockNow); + }); + + afterAll(() => { + jest.resetAllMocks(); + }); + + it('stores counter in a saved object', async () => { + const counterMetric: CounterMetric = { + domainId: 'a', + counterName: 'b', + counterType: 'c', + incrementBy: 13, + }; + + await storeCounter(counterMetric, internalRepository); + + expect(internalRepository.incrementCounter).toBeCalledTimes(1); + expect(internalRepository.incrementCounter.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "usage-counters", + "a:09042021:c:b", + Array [ + Object { + "fieldName": "count", + "incrementBy": 13, + }, + ], + Object { + "upsertAttributes": Object { + "counterName": "b", + "counterType": "c", + "domainId": "a", + }, + }, + ] + `); + }); +}); diff --git a/src/plugins/usage_collection/server/usage_counters/saved_objects.ts b/src/plugins/usage_collection/server/usage_counters/saved_objects.ts new file mode 100644 index 00000000000000..6c585d756e8c1b --- /dev/null +++ b/src/plugins/usage_collection/server/usage_counters/saved_objects.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + SavedObject, + SavedObjectsRepository, + SavedObjectAttributes, + SavedObjectsServiceSetup, +} from 'kibana/server'; +import moment from 'moment'; +import { CounterMetric } from './usage_counter'; + +export interface UsageCountersSavedObjectAttributes extends SavedObjectAttributes { + domainId: string; + counterName: string; + counterType: string; + count: number; +} + +export type UsageCountersSavedObject = SavedObject; + +export const USAGE_COUNTERS_SAVED_OBJECT_TYPE = 'usage-counters'; + +export const registerUsageCountersSavedObjectType = ( + savedObjectsSetup: SavedObjectsServiceSetup +) => { + savedObjectsSetup.registerType({ + name: USAGE_COUNTERS_SAVED_OBJECT_TYPE, + hidden: false, + namespaceType: 'agnostic', + mappings: { + dynamic: false, + properties: { + domainId: { type: 'keyword' }, + }, + }, + }); +}; + +export interface SerializeCounterParams { + domainId: string; + counterName: string; + counterType: string; + date: moment.MomentInput; +} + +export const serializeCounterKey = ({ + domainId, + counterName, + counterType, + date, +}: SerializeCounterParams) => { + const dayDate = moment(date).format('DDMMYYYY'); + return `${domainId}:${dayDate}:${counterType}:${counterName}`; +}; + +export const storeCounter = async ( + counterMetric: CounterMetric, + internalRepository: Pick +) => { + const { counterName, counterType, domainId, incrementBy } = counterMetric; + const key = serializeCounterKey({ + date: moment.now(), + domainId, + counterName, + counterType, + }); + + return await internalRepository.incrementCounter( + USAGE_COUNTERS_SAVED_OBJECT_TYPE, + key, + [{ fieldName: 'count', incrementBy }], + { + upsertAttributes: { + domainId, + counterName, + counterType, + }, + } + ); +}; diff --git a/src/plugins/usage_collection/server/usage_counters/usage_counter.test.ts b/src/plugins/usage_collection/server/usage_counters/usage_counter.test.ts new file mode 100644 index 00000000000000..3602ff1a29376b --- /dev/null +++ b/src/plugins/usage_collection/server/usage_counters/usage_counter.test.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { UsageCounter, CounterMetric } from './usage_counter'; +import * as Rx from 'rxjs'; +import * as rxOp from 'rxjs/operators'; + +describe('UsageCounter', () => { + const domainId = 'test-domain-id'; + const counter$ = new Rx.Subject(); + const usageCounter = new UsageCounter({ domainId, counter$ }); + + afterAll(() => { + counter$.complete(); + }); + + describe('#incrementCounter', () => { + it('#incrementCounter calls counter$.next', async () => { + const result = counter$.pipe(rxOp.take(1), rxOp.toArray()).toPromise(); + usageCounter.incrementCounter({ counterName: 'test', counterType: 'type', incrementBy: 13 }); + await expect(result).resolves.toEqual([ + { counterName: 'test', counterType: 'type', domainId: 'test-domain-id', incrementBy: 13 }, + ]); + }); + + it('passes default configs to counter$', async () => { + const result = counter$.pipe(rxOp.take(1), rxOp.toArray()).toPromise(); + usageCounter.incrementCounter({ counterName: 'test' }); + await expect(result).resolves.toEqual([ + { counterName: 'test', counterType: 'count', domainId: 'test-domain-id', incrementBy: 1 }, + ]); + }); + }); +}); diff --git a/src/plugins/usage_collection/server/usage_counters/usage_counter.ts b/src/plugins/usage_collection/server/usage_counters/usage_counter.ts new file mode 100644 index 00000000000000..af00ad04149b73 --- /dev/null +++ b/src/plugins/usage_collection/server/usage_counters/usage_counter.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Rx from 'rxjs'; + +export interface CounterMetric { + domainId: string; + counterName: string; + counterType: string; + incrementBy: number; +} + +export interface UsageCounterDeps { + domainId: string; + counter$: Rx.Subject; +} + +export interface IncrementCounterParams { + counterName: string; + counterType?: string; + incrementBy?: number; +} + +export class UsageCounter { + private domainId: string; + private counter$: Rx.Subject; + + constructor({ domainId, counter$ }: UsageCounterDeps) { + this.domainId = domainId; + this.counter$ = counter$; + } + + public incrementCounter = (params: IncrementCounterParams) => { + const { counterName, counterType = 'count', incrementBy = 1 } = params; + + this.counter$.next({ + counterName, + domainId: this.domainId, + counterType, + incrementBy, + }); + }; +} diff --git a/src/plugins/usage_collection/server/usage_counters/usage_counters_service.mock.ts b/src/plugins/usage_collection/server/usage_counters/usage_counters_service.mock.ts new file mode 100644 index 00000000000000..beb67d1eb26073 --- /dev/null +++ b/src/plugins/usage_collection/server/usage_counters/usage_counters_service.mock.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { PublicMethodsOf } from '@kbn/utility-types'; +import type { UsageCountersService, UsageCountersServiceSetup } from './usage_counters_service'; +import type { UsageCounter } from './usage_counter'; + +const createSetupContractMock = () => { + const setupContract: jest.Mocked = { + createUsageCounter: jest.fn(), + getUsageCounterByType: jest.fn(), + }; + + setupContract.createUsageCounter.mockReturnValue(({ + incrementCounter: jest.fn(), + } as unknown) as jest.Mocked); + + return setupContract; +}; + +const createUsageCountersServiceMock = () => { + const mocked: jest.Mocked> = { + setup: jest.fn(), + start: jest.fn(), + stop: jest.fn(), + }; + + mocked.setup.mockReturnValue(createSetupContractMock()); + return mocked; +}; + +export const usageCountersServiceMock = { + create: createUsageCountersServiceMock, + createSetupContract: createSetupContractMock, +}; diff --git a/src/plugins/usage_collection/server/usage_counters/usage_counters_service.test.ts b/src/plugins/usage_collection/server/usage_counters/usage_counters_service.test.ts new file mode 100644 index 00000000000000..c800bce6390c9c --- /dev/null +++ b/src/plugins/usage_collection/server/usage_counters/usage_counters_service.test.ts @@ -0,0 +1,241 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/* eslint-disable dot-notation */ +import { UsageCountersService } from './usage_counters_service'; +import { loggingSystemMock, coreMock } from '../../../../core/server/mocks'; +import * as rxOp from 'rxjs/operators'; +import moment from 'moment'; + +const tick = () => { + jest.useRealTimers(); + return new Promise((resolve) => setTimeout(resolve, 1)); +}; + +describe('UsageCountersService', () => { + const retryCount = 1; + const bufferDurationMs = 100; + const mockNow = 1617954426939; + const logger = loggingSystemMock.createLogger(); + const coreSetup = coreMock.createSetup(); + const coreStart = coreMock.createStart(); + + beforeEach(() => { + jest.spyOn(moment, 'now').mockReturnValue(mockNow); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('stores data in cache during setup', async () => { + const usageCountersService = new UsageCountersService({ logger, retryCount, bufferDurationMs }); + const { createUsageCounter } = usageCountersService.setup(coreSetup); + + const usageCounter = createUsageCounter('test-counter'); + + usageCounter.incrementCounter({ counterName: 'counterA' }); + usageCounter.incrementCounter({ counterName: 'counterA' }); + + const dataInSourcePromise = usageCountersService['source$'].pipe(rxOp.toArray()).toPromise(); + usageCountersService['flushCache$'].next(); + usageCountersService['source$'].complete(); + await expect(dataInSourcePromise).resolves.toHaveLength(2); + }); + + it('registers savedObject type during setup', () => { + const usageCountersService = new UsageCountersService({ logger, retryCount, bufferDurationMs }); + usageCountersService.setup(coreSetup); + expect(coreSetup.savedObjects.registerType).toBeCalledTimes(1); + }); + + it('flushes cached data on start', async () => { + const usageCountersService = new UsageCountersService({ logger, retryCount, bufferDurationMs }); + + const mockRepository = coreStart.savedObjects.createInternalRepository(); + const mockIncrementCounter = jest.fn(); + mockRepository.incrementCounter = mockIncrementCounter; + + coreStart.savedObjects.createInternalRepository.mockReturnValue(mockRepository); + const { createUsageCounter } = usageCountersService.setup(coreSetup); + + const usageCounter = createUsageCounter('test-counter'); + + usageCounter.incrementCounter({ counterName: 'counterA' }); + usageCounter.incrementCounter({ counterName: 'counterA' }); + + const dataInSourcePromise = usageCountersService['source$'].pipe(rxOp.toArray()).toPromise(); + usageCountersService.start(coreStart); + usageCountersService['source$'].complete(); + + await expect(dataInSourcePromise).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "counterName": "counterA", + "counterType": "count", + "domainId": "test-counter", + "incrementBy": 1, + }, + Object { + "counterName": "counterA", + "counterType": "count", + "domainId": "test-counter", + "incrementBy": 1, + }, + ] + `); + }); + + it('buffers data into savedObject', async () => { + const usageCountersService = new UsageCountersService({ logger, retryCount, bufferDurationMs }); + + const mockRepository = coreStart.savedObjects.createInternalRepository(); + const mockIncrementCounter = jest.fn().mockResolvedValue('success'); + mockRepository.incrementCounter = mockIncrementCounter; + + coreStart.savedObjects.createInternalRepository.mockReturnValue(mockRepository); + const { createUsageCounter } = usageCountersService.setup(coreSetup); + jest.useFakeTimers('modern'); + const usageCounter = createUsageCounter('test-counter'); + + usageCounter.incrementCounter({ counterName: 'counterA' }); + usageCounter.incrementCounter({ counterName: 'counterA' }); + + usageCountersService.start(coreStart); + usageCounter.incrementCounter({ counterName: 'counterA' }); + usageCounter.incrementCounter({ counterName: 'counterB' }); + jest.runOnlyPendingTimers(); + expect(mockIncrementCounter).toBeCalledTimes(2); + expect(mockIncrementCounter.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "usage-counters", + "test-counter:09042021:count:counterA", + Array [ + Object { + "fieldName": "count", + "incrementBy": 3, + }, + ], + Object { + "upsertAttributes": Object { + "counterName": "counterA", + "counterType": "count", + "domainId": "test-counter", + }, + }, + ], + Array [ + "usage-counters", + "test-counter:09042021:count:counterB", + Array [ + Object { + "fieldName": "count", + "incrementBy": 1, + }, + ], + Object { + "upsertAttributes": Object { + "counterName": "counterB", + "counterType": "count", + "domainId": "test-counter", + }, + }, + ], + ] + `); + }); + + it('retries errors by `retryCount` times before failing to store', async () => { + const usageCountersService = new UsageCountersService({ + logger, + retryCount: 1, + bufferDurationMs, + }); + + const mockRepository = coreStart.savedObjects.createInternalRepository(); + const mockError = new Error('failed.'); + const mockIncrementCounter = jest.fn().mockImplementation((_, key) => { + switch (key) { + case 'test-counter:09042021:count:counterA': + throw mockError; + case 'test-counter:09042021:count:counterB': + return 'pass'; + default: + throw new Error(`unknown key ${key}`); + } + }); + + mockRepository.incrementCounter = mockIncrementCounter; + + coreStart.savedObjects.createInternalRepository.mockReturnValue(mockRepository); + const { createUsageCounter } = usageCountersService.setup(coreSetup); + jest.useFakeTimers('modern'); + const usageCounter = createUsageCounter('test-counter'); + + usageCountersService.start(coreStart); + usageCounter.incrementCounter({ counterName: 'counterA' }); + usageCounter.incrementCounter({ counterName: 'counterB' }); + jest.runOnlyPendingTimers(); + + // wait for retries to kick in on next scheduler call + await tick(); + // number of incrementCounter calls + number of retries + expect(mockIncrementCounter).toBeCalledTimes(2 + 1); + expect(logger.debug).toHaveBeenNthCalledWith(1, 'Store counters into savedObjects', [ + mockError, + 'pass', + ]); + }); + + it('buffers counters within `bufferDurationMs` time', async () => { + const usageCountersService = new UsageCountersService({ + logger, + retryCount, + bufferDurationMs: 30000, + }); + + const mockRepository = coreStart.savedObjects.createInternalRepository(); + const mockIncrementCounter = jest.fn().mockImplementation((_data, key, counter) => { + expect(counter).toHaveLength(1); + return { key, incrementBy: counter[0].incrementBy }; + }); + + mockRepository.incrementCounter = mockIncrementCounter; + + coreStart.savedObjects.createInternalRepository.mockReturnValue(mockRepository); + + const { createUsageCounter } = usageCountersService.setup(coreSetup); + jest.useFakeTimers('modern'); + const usageCounter = createUsageCounter('test-counter'); + + usageCountersService.start(coreStart); + usageCounter.incrementCounter({ counterName: 'counterA' }); + usageCounter.incrementCounter({ counterName: 'counterA' }); + jest.advanceTimersByTime(30000); + + usageCounter.incrementCounter({ counterName: 'counterA' }); + jest.runOnlyPendingTimers(); + + // wait for debounce to kick in on next scheduler call + await tick(); + expect(mockIncrementCounter).toBeCalledTimes(2); + expect(mockIncrementCounter.mock.results.map(({ value }) => value)).toMatchInlineSnapshot(` + Array [ + Object { + "incrementBy": 2, + "key": "test-counter:09042021:count:counterA", + }, + Object { + "incrementBy": 1, + "key": "test-counter:09042021:count:counterA", + }, + ] + `); + }); +}); diff --git a/src/plugins/usage_collection/server/usage_counters/usage_counters_service.ts b/src/plugins/usage_collection/server/usage_counters/usage_counters_service.ts new file mode 100644 index 00000000000000..88ca9f6358926a --- /dev/null +++ b/src/plugins/usage_collection/server/usage_counters/usage_counters_service.ts @@ -0,0 +1,185 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as Rx from 'rxjs'; +import * as rxOp from 'rxjs/operators'; +import { + SavedObjectsRepository, + SavedObjectsServiceSetup, + SavedObjectsServiceStart, +} from 'src/core/server'; +import type { Logger } from 'src/core/server'; + +import moment from 'moment'; +import { CounterMetric, UsageCounter } from './usage_counter'; +import { + registerUsageCountersSavedObjectType, + storeCounter, + serializeCounterKey, +} from './saved_objects'; + +export interface UsageCountersServiceDeps { + logger: Logger; + retryCount: number; + bufferDurationMs: number; +} + +export interface UsageCountersServiceSetup { + createUsageCounter: (type: string) => UsageCounter; + getUsageCounterByType: (type: string) => UsageCounter | undefined; +} + +/* internal */ +export interface UsageCountersServiceSetupDeps { + savedObjects: SavedObjectsServiceSetup; +} + +/* internal */ +export interface UsageCountersServiceStartDeps { + savedObjects: SavedObjectsServiceStart; +} + +export class UsageCountersService { + private readonly stop$ = new Rx.Subject(); + private readonly retryCount: number; + private readonly bufferDurationMs: number; + + private readonly counterSets = new Map(); + private readonly source$ = new Rx.Subject(); + private readonly counter$ = this.source$.pipe(rxOp.multicast(new Rx.Subject()), rxOp.refCount()); + private readonly flushCache$ = new Rx.Subject(); + + private readonly stopCaching$ = new Rx.Subject(); + + private readonly logger: Logger; + + constructor({ logger, retryCount, bufferDurationMs }: UsageCountersServiceDeps) { + this.logger = logger; + this.retryCount = retryCount; + this.bufferDurationMs = bufferDurationMs; + } + + public setup = (core: UsageCountersServiceSetupDeps): UsageCountersServiceSetup => { + const cache$ = new Rx.ReplaySubject(); + const storingCache$ = new Rx.BehaviorSubject(false); + // flush cache data from cache -> source + this.flushCache$ + .pipe( + rxOp.exhaustMap(() => cache$), + rxOp.takeUntil(this.stop$) + ) + .subscribe((data) => { + storingCache$.next(true); + this.source$.next(data); + }); + + // store data into cache when not paused + storingCache$ + .pipe( + rxOp.distinctUntilChanged(), + rxOp.switchMap((isStoring) => (isStoring ? Rx.EMPTY : this.source$)), + rxOp.takeUntil(Rx.merge(this.stopCaching$, this.stop$)) + ) + .subscribe((data) => { + cache$.next(data); + storingCache$.next(false); + }); + + registerUsageCountersSavedObjectType(core.savedObjects); + + return { + createUsageCounter: this.createUsageCounter, + getUsageCounterByType: this.getUsageCounterByType, + }; + }; + + public start = ({ savedObjects }: UsageCountersServiceStartDeps): void => { + this.stopCaching$.next(); + const internalRepository = savedObjects.createInternalRepository(); + this.counter$ + .pipe( + /* buffer source events every ${bufferDurationMs} */ + rxOp.bufferTime(this.bufferDurationMs), + /** + * bufferTime will trigger every ${bufferDurationMs} + * regardless if source emitted anything or not. + * using filter will stop cut the pipe short + */ + rxOp.filter((counters) => Array.isArray(counters) && counters.length > 0), + rxOp.map((counters) => Object.values(this.mergeCounters(counters))), + rxOp.takeUntil(this.stop$), + rxOp.concatMap((counters) => this.storeDate$(counters, internalRepository)) + ) + .subscribe((results) => { + this.logger.debug('Store counters into savedObjects', results); + }); + + this.flushCache$.next(); + }; + + public stop = () => { + this.stop$.next(); + }; + + private storeDate$( + counters: CounterMetric[], + internalRepository: Pick + ) { + return Rx.forkJoin( + counters.map((counter) => + Rx.defer(() => storeCounter(counter, internalRepository)).pipe( + rxOp.retry(this.retryCount), + rxOp.catchError((error) => { + this.logger.warn(error); + return Rx.of(error); + }) + ) + ) + ); + } + + private createUsageCounter = (type: string): UsageCounter => { + if (this.counterSets.get(type)) { + throw new Error(`Usage counter set "${type}" already exists.`); + } + + const counterSet = new UsageCounter({ + domainId: type, + counter$: this.source$, + }); + + this.counterSets.set(type, counterSet); + + return counterSet; + }; + + private getUsageCounterByType = (type: string): UsageCounter | undefined => { + return this.counterSets.get(type); + }; + + private mergeCounters = (counters: CounterMetric[]): Record => { + const date = moment.now(); + return counters.reduce((acc, counter) => { + const { counterName, domainId, counterType } = counter; + const key = serializeCounterKey({ domainId, counterName, counterType, date }); + const existingCounter = acc[key]; + if (!existingCounter) { + acc[key] = counter; + return acc; + } + return { + ...acc, + [key]: { + ...existingCounter, + ...counter, + incrementBy: existingCounter.incrementBy + counter.incrementBy, + }, + }; + }, {} as Record); + }; +} diff --git a/src/plugins/vis_type_table/server/usage_collector/register_usage_collector.test.ts b/src/plugins/vis_type_table/server/usage_collector/register_usage_collector.test.ts index b87e6d54733afb..e045788897b61e 100644 --- a/src/plugins/vis_type_table/server/usage_collector/register_usage_collector.test.ts +++ b/src/plugins/vis_type_table/server/usage_collector/register_usage_collector.test.ts @@ -10,8 +10,10 @@ jest.mock('./get_stats', () => ({ getStats: jest.fn().mockResolvedValue({ somestat: 1 }), })); -import { createUsageCollectionSetupMock } from 'src/plugins/usage_collection/server/usage_collection.mock'; -import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks'; +import { + createUsageCollectionSetupMock, + createCollectorFetchContextMock, +} from 'src/plugins/usage_collection/server/mocks'; import { registerVisTypeTableUsageCollector } from './register_usage_collector'; import { getStats } from './get_stats'; diff --git a/src/plugins/vis_type_timeseries/server/usage_collector/register_timeseries_collector.test.ts b/src/plugins/vis_type_timeseries/server/usage_collector/register_timeseries_collector.test.ts index 2612a3882af2d0..726ad972ab8d1b 100644 --- a/src/plugins/vis_type_timeseries/server/usage_collector/register_timeseries_collector.test.ts +++ b/src/plugins/vis_type_timeseries/server/usage_collector/register_timeseries_collector.test.ts @@ -8,7 +8,7 @@ import { of } from 'rxjs'; import { mockStats, mockGetStats } from './get_usage_collector.mock'; -import { createUsageCollectionSetupMock } from 'src/plugins/usage_collection/server/usage_collection.mock'; +import { createUsageCollectionSetupMock } from 'src/plugins/usage_collection/server/mocks'; import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks'; import { registerTimeseriesUsageCollector } from './register_timeseries_collector'; import { ConfigObservable } from '../types'; diff --git a/src/plugins/vis_type_vega/server/usage_collector/register_vega_collector.test.ts b/src/plugins/vis_type_vega/server/usage_collector/register_vega_collector.test.ts index 9db1b7657f4447..7933da3e675f63 100644 --- a/src/plugins/vis_type_vega/server/usage_collector/register_vega_collector.test.ts +++ b/src/plugins/vis_type_vega/server/usage_collector/register_vega_collector.test.ts @@ -8,7 +8,7 @@ import { of } from 'rxjs'; import { mockStats, mockGetStats } from './get_usage_collector.mock'; -import { createUsageCollectionSetupMock } from 'src/plugins/usage_collection/server/usage_collection.mock'; +import { createUsageCollectionSetupMock } from 'src/plugins/usage_collection/server/mocks'; import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks'; import { HomeServerPluginSetup } from '../../../home/server'; import { registerVegaUsageCollector } from './register_vega_collector'; diff --git a/src/plugins/visualizations/server/usage_collector/register_visualizations_collector.test.ts b/src/plugins/visualizations/server/usage_collector/register_visualizations_collector.test.ts index 743ec29fe9af7c..a3617631f734ba 100644 --- a/src/plugins/visualizations/server/usage_collector/register_visualizations_collector.test.ts +++ b/src/plugins/visualizations/server/usage_collector/register_visualizations_collector.test.ts @@ -8,7 +8,7 @@ import { of } from 'rxjs'; import { mockStats, mockGetStats } from './get_usage_collector.mock'; -import { createUsageCollectionSetupMock } from 'src/plugins/usage_collection/server/usage_collection.mock'; +import { createUsageCollectionSetupMock } from 'src/plugins/usage_collection/server/mocks'; import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks'; import { registerVisualizationsCollector } from './register_visualizations_collector'; diff --git a/test/api_integration/apis/telemetry/__fixtures__/ui_counters.ts b/test/api_integration/apis/telemetry/__fixtures__/ui_counters.ts index 762b9179182026..07a11f3876d868 100644 --- a/test/api_integration/apis/telemetry/__fixtures__/ui_counters.ts +++ b/test/api_integration/apis/telemetry/__fixtures__/ui_counters.ts @@ -8,6 +8,14 @@ export const basicUiCounters = { dailyEvents: [ + { + appName: 'myApp', + eventName: 'some_app_event', + lastUpdatedAt: '2021-11-20T11:43:00.961Z', + fromTimestamp: '2021-11-20T00:00:00Z', + counterType: 'count', + total: 2, + }, { appName: 'myApp', eventName: 'my_event_885082425109579', diff --git a/test/api_integration/apis/telemetry/__fixtures__/usage_counters.ts b/test/api_integration/apis/telemetry/__fixtures__/usage_counters.ts new file mode 100644 index 00000000000000..988bc2e77528de --- /dev/null +++ b/test/api_integration/apis/telemetry/__fixtures__/usage_counters.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const basicUsageCounters = { + dailyEvents: [ + { + domainId: 'anotherDomainId', + counterName: 'some_event_name', + counterType: 'count', + lastUpdatedAt: '2021-11-20T11:43:00.961Z', + fromTimestamp: '2021-11-20T00:00:00Z', + total: 3, + }, + { + domainId: 'anotherDomainId', + counterName: 'some_event_name', + counterType: 'count', + lastUpdatedAt: '2021-04-09T11:43:00.961Z', + fromTimestamp: '2021-04-09T00:00:00Z', + total: 2, + }, + { + domainId: 'anotherDomainId2', + counterName: 'some_event_name', + counterType: 'count', + lastUpdatedAt: '2021-04-20T08:18:03.030Z', + fromTimestamp: '2021-04-20T00:00:00Z', + total: 1, + }, + ], +}; diff --git a/test/api_integration/apis/telemetry/telemetry_local.ts b/test/api_integration/apis/telemetry/telemetry_local.ts index d0a09ee58d3359..9b92576c84b3a9 100644 --- a/test/api_integration/apis/telemetry/telemetry_local.ts +++ b/test/api_integration/apis/telemetry/telemetry_local.ts @@ -9,6 +9,7 @@ import expect from '@kbn/expect'; import supertestAsPromised from 'supertest-as-promised'; import { basicUiCounters } from './__fixtures__/ui_counters'; +import { basicUsageCounters } from './__fixtures__/usage_counters'; import type { FtrProviderContext } from '../../ftr_provider_context'; import type { SavedObject } from '../../../../src/core/server'; import ossRootTelemetrySchema from '../../../../src/plugins/telemetry/schema/oss_root.json'; @@ -153,6 +154,20 @@ export default function ({ getService }: FtrProviderContext) { }); }); + describe('Usage Counters telemetry', () => { + before('Add UI Counters saved objects', () => + esArchiver.load('saved_objects/usage_counters') + ); + after('cleanup saved objects changes', () => + esArchiver.unload('saved_objects/usage_counters') + ); + + it('returns usage counters aggregated by day', async () => { + const stats = await retrieveTelemetry(supertest); + expect(stats.stack_stats.kibana.plugins.usage_counters).to.eql(basicUsageCounters); + }); + }); + describe('application usage limits', () => { function createSavedObject(viewId?: string) { return supertest diff --git a/test/api_integration/apis/ui_counters/ui_counters.ts b/test/api_integration/apis/ui_counters/ui_counters.ts index 2d55e224f31cee..aa201eb6a96ff5 100644 --- a/test/api_integration/apis/ui_counters/ui_counters.ts +++ b/test/api_integration/apis/ui_counters/ui_counters.ts @@ -7,11 +7,10 @@ */ import expect from '@kbn/expect'; -import { ReportManager, METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics'; +import { ReportManager, METRIC_TYPE, UiCounterMetricType, Report } from '@kbn/analytics'; import moment from 'moment'; import { FtrProviderContext } from '../../ftr_provider_context'; -import { SavedObject } from '../../../../src/core/server'; -import { UICounterSavedObjectAttributes } from '../../../../src/plugins/kibana_usage_collection/server/collectors/ui_counters/ui_counter_saved_object_type'; +import { UsageCountersSavedObject } from '../../../../src/plugins/usage_collection/server'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -24,10 +23,22 @@ export default function ({ getService }: FtrProviderContext) { count, }); + const sendReport = async (report: Report) => { + await supertest + .post('/api/ui_counters/_report') + .set('kbn-xsrf', 'kibana') + .set('content-type', 'application/json') + .send({ report }) + .expect(200); + + // wait for SO to index data into ES + await new Promise((res) => setTimeout(res, 5 * 1000)); + }; + const getCounterById = ( - savedObjects: Array>, + savedObjects: UsageCountersSavedObject[], targetId: string - ): SavedObject => { + ): UsageCountersSavedObject => { const savedObject = savedObjects.find(({ id }: { id: string }) => id === targetId); if (!savedObject) { throw new Error(`Unable to find savedObject id ${targetId}`); @@ -40,30 +51,25 @@ export default function ({ getService }: FtrProviderContext) { const dayDate = moment().format('DDMMYYYY'); before(async () => await esArchiver.emptyKibanaIndex()); - it('stores ui counter events in savedObjects', async () => { + it('stores ui counter events in usage counters savedObjects', async () => { const reportManager = new ReportManager(); const { report } = reportManager.assignReports([ createUiCounterEvent('my_event', METRIC_TYPE.COUNT), ]); - await supertest - .post('/api/ui_counters/_report') - .set('kbn-xsrf', 'kibana') - .set('content-type', 'application/json') - .send({ report }) - .expect(200); + await sendReport(report); const { body: { saved_objects: savedObjects }, } = await supertest - .get('/api/saved_objects/_find?type=ui-counter') + .get('/api/saved_objects/_find?type=usage-counters') .set('kbn-xsrf', 'kibana') .expect(200); const countTypeEvent = getCounterById( savedObjects, - `myApp:${dayDate}:${METRIC_TYPE.COUNT}:my_event` + `uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:my_event` ); expect(countTypeEvent.attributes.count).to.eql(1); }); @@ -78,35 +84,31 @@ export default function ({ getService }: FtrProviderContext) { createUiCounterEvent(`${uniqueEventName}_2`, METRIC_TYPE.COUNT), createUiCounterEvent(uniqueEventName, METRIC_TYPE.CLICK, 2), ]); - await supertest - .post('/api/ui_counters/_report') - .set('kbn-xsrf', 'kibana') - .set('content-type', 'application/json') - .send({ report }) - .expect(200); + + await sendReport(report); const { body: { saved_objects: savedObjects }, } = await supertest - .get('/api/saved_objects/_find?type=ui-counter&fields=count') + .get('/api/saved_objects/_find?type=usage-counters&fields=count') .set('kbn-xsrf', 'kibana') .expect(200); const countTypeEvent = getCounterById( savedObjects, - `myApp:${dayDate}:${METRIC_TYPE.COUNT}:${uniqueEventName}` + `uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:${uniqueEventName}` ); expect(countTypeEvent.attributes.count).to.eql(1); const clickTypeEvent = getCounterById( savedObjects, - `myApp:${dayDate}:${METRIC_TYPE.CLICK}:${uniqueEventName}` + `uiCounter:${dayDate}:${METRIC_TYPE.CLICK}:myApp:${uniqueEventName}` ); expect(clickTypeEvent.attributes.count).to.eql(2); const secondEvent = getCounterById( savedObjects, - `myApp:${dayDate}:${METRIC_TYPE.COUNT}:${uniqueEventName}_2` + `uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:${uniqueEventName}_2` ); expect(secondEvent.attributes.count).to.eql(1); }); diff --git a/test/api_integration/config.js b/test/api_integration/config.js index 1c19dd24fa96b6..7bbace4c60570b 100644 --- a/test/api_integration/config.js +++ b/test/api_integration/config.js @@ -31,6 +31,8 @@ export default async function ({ readConfigFile }) { '--server.xsrf.disableProtection=true', '--server.compression.referrerWhitelist=["some-host.com"]', `--savedObjects.maxImportExportSize=10001`, + // for testing set buffer duration to 0 to immediately flush counters into saved objects. + '--usageCollection.usageCounters.bufferDuration=0', ], }, }; diff --git a/test/api_integration/fixtures/es_archiver/saved_objects/ui_counters/data.json b/test/api_integration/fixtures/es_archiver/saved_objects/ui_counters/data.json new file mode 100644 index 00000000000000..80071fe4227801 --- /dev/null +++ b/test/api_integration/fixtures/es_archiver/saved_objects/ui_counters/data.json @@ -0,0 +1,111 @@ +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "ui-counter:myApp:30112020:loaded:my_event_885082425109579", + "source": { + "ui-counter": { + "count": 1 + }, + "type": "ui-counter", + "updated_at": "2020-11-30T11:43:00.961Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "ui-counter:myApp:30112020:count:my_event_885082425109579_2", + "source": { + "ui-counter": { + "count": 1 + }, + "type": "ui-counter", + "updated_at": "2020-11-30T11:43:00.961Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "ui-counter:myApp:30112020:count:my_event_885082425109579_2", + "source": { + "ui-counter": { + "count": 1 + }, + "type": "ui-counter", + "updated_at": "2020-10-28T11:43:00.961Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "ui-counter:myApp:30112020:click:my_event_885082425109579", + "source": { + "ui-counter": { + "count": 2 + }, + "type": "ui-counter", + "updated_at": "2020-11-30T11:43:00.961Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "ui-counter:myApp:30112020:click:my_event_885082425109579", + "source": { + "ui-counter": { + "count": 2 + }, + "type": "ui-counter", + "updated_at": "2020-11-30T11:43:00.961Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "uiCounter:09042021:count:myApp:some_app_event", + "source": { + "usage-counters": { + "count": 2, + "domainId": "uiCounter", + "counterName": "myApp:some_app_event", + "counterType": "count" + }, + "type": "usage-counters", + "updated_at": "2021-11-20T11:43:00.961Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "anotherDomainId:09042021:count:some_event_name", + "source": { + "usage-counters": { + "count": 2, + "domainId": "anotherDomainId", + "counterName": "some_event_name", + "counterType": "count" + }, + "type": "usage-counters", + "updated_at": "2021-11-20T11:43:00.961Z" + } + } +} + diff --git a/test/api_integration/fixtures/es_archiver/saved_objects/ui_counters/data.json.gz b/test/api_integration/fixtures/es_archiver/saved_objects/ui_counters/data.json.gz deleted file mode 100644 index 3f42c777260b3bb8c9892f0b4e7c1ed0f18292ed..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 236 zcmVQOZ*BnXld%qhFc5}!o`Q6yXaMqJu++`}+TP?Von^e4lhfqX_qj)CCC~=tX558Es+9vX<)Z1mUGT zi(1Sg$EAa&q=hzhr&@j;4o$-&KxDvxS6WCVEzMQ0>Ml>y1X32W1R+cI+0y2wOfof+Hf2BMuN|J3NtDK6!3Uo;Pk8 m%#1(glCys@znBbAmVPmrsw^%W{3W*ei+KQ7tJo%F1ONd3YHSDq diff --git a/test/api_integration/fixtures/es_archiver/saved_objects/ui_counters/mappings.json b/test/api_integration/fixtures/es_archiver/saved_objects/ui_counters/mappings.json index 926fd5d79faa08..39902f8a9211a6 100644 --- a/test/api_integration/fixtures/es_archiver/saved_objects/ui_counters/mappings.json +++ b/test/api_integration/fixtures/es_archiver/saved_objects/ui_counters/mappings.json @@ -35,6 +35,15 @@ } } }, + "usage-counters": { + "dynamic": false, + "properties": { + "domainId": { + "type": "keyword", + "ignore_above": 256 + } + } + }, "dashboard": { "properties": { "description": { diff --git a/test/api_integration/fixtures/es_archiver/saved_objects/usage_counters/data.json b/test/api_integration/fixtures/es_archiver/saved_objects/usage_counters/data.json new file mode 100644 index 00000000000000..16e0364b24fda8 --- /dev/null +++ b/test/api_integration/fixtures/es_archiver/saved_objects/usage_counters/data.json @@ -0,0 +1,89 @@ +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "uiCounter:20112020:count:myApp:some_app_event", + "source": { + "usage-counters": { + "count": 2, + "domainId": "uiCounter", + "counterName": "myApp:some_app_event", + "counterType": "count" + }, + "type": "usage-counters", + "updated_at": "2021-11-20T11:43:00.961Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "anotherDomainId:20112020:count:some_event_name", + "source": { + "usage-counters": { + "count": 3, + "domainId": "anotherDomainId", + "counterName": "some_event_name", + "counterType": "count" + }, + "type": "usage-counters", + "updated_at": "2021-11-20T11:43:00.961Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "anotherDomainId:09042021:count:some_event_name", + "source": { + "usage-counters": { + "count": 2, + "domainId": "anotherDomainId", + "counterName": "some_event_name", + "counterType": "count" + }, + "type": "usage-counters", + "updated_at": "2021-04-09T11:43:00.961Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "anotherDomainId2:09042021:count:some_event_name", + "source": { + "usage-counters": { + "count": 1, + "domainId": "anotherDomainId2", + "counterName": "some_event_name", + "counterType": "count" + }, + "type": "usage-counters", + "updated_at": "2021-04-20T08:18:03.030Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "anotherDomainId3:09042021:custom_type:zero_count", + "source": { + "usage-counters": { + "count": 0, + "domainId": "anotherDomainId3", + "counterName": "zero_count", + "counterType": "custom_type" + }, + "type": "usage-counters", + "updated_at": "2021-04-20T08:18:03.030Z" + } + } +} diff --git a/test/api_integration/fixtures/es_archiver/saved_objects/usage_counters/mappings.json b/test/api_integration/fixtures/es_archiver/saved_objects/usage_counters/mappings.json new file mode 100644 index 00000000000000..14ed147b2da8e7 --- /dev/null +++ b/test/api_integration/fixtures/es_archiver/saved_objects/usage_counters/mappings.json @@ -0,0 +1,276 @@ +{ + "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 + } + } + } + } + }, + "usage-counters": { + "dynamic": false, + "properties": { + "domainId": { + "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/plugin_functional/config.ts b/test/plugin_functional/config.ts index 1651e213ee82d6..d21a157975ac83 100644 --- a/test/plugin_functional/config.ts +++ b/test/plugin_functional/config.ts @@ -21,6 +21,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { return { testFiles: [ + require.resolve('./test_suites/usage_collection'), require.resolve('./test_suites/core'), require.resolve('./test_suites/custom_visualizations'), require.resolve('./test_suites/panel_actions'), @@ -59,6 +60,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { '--corePluginDeprecations.oldProperty=hello', '--corePluginDeprecations.secret=100', '--corePluginDeprecations.noLongerUsed=still_using', + // for testing set buffer duration to 0 to immediately flush counters into saved objects. + '--usageCollection.usageCounters.bufferDuration=0', ...plugins.map( (pluginDir) => `--plugin-path=${path.resolve(__dirname, 'plugins', pluginDir)}` ), diff --git a/test/plugin_functional/plugins/usage_collection/kibana.json b/test/plugin_functional/plugins/usage_collection/kibana.json new file mode 100644 index 00000000000000..c98e3b95d389c9 --- /dev/null +++ b/test/plugin_functional/plugins/usage_collection/kibana.json @@ -0,0 +1,9 @@ +{ + "id": "usageCollectionTestPlugin", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["usageCollectionTestPlugin"], + "requiredPlugins": ["usageCollection"], + "server": true, + "ui": false +} diff --git a/test/plugin_functional/plugins/usage_collection/package.json b/test/plugin_functional/plugins/usage_collection/package.json new file mode 100644 index 00000000000000..33289bd8d727f1 --- /dev/null +++ b/test/plugin_functional/plugins/usage_collection/package.json @@ -0,0 +1,14 @@ +{ + "name": "usage_collection_test_plugin", + "version": "1.0.0", + "main": "target/test/plugin_functional/plugins/usage_collection", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "SSPL-1.0 OR Elastic License 2.0", + "scripts": { + "kbn": "node ../../../../scripts/kbn.js", + "build": "rm -rf './target' && ../../../../node_modules/.bin/tsc" + } +} diff --git a/test/plugin_functional/plugins/usage_collection/server/index.ts b/test/plugin_functional/plugins/usage_collection/server/index.ts new file mode 100644 index 00000000000000..172f8491a1a407 --- /dev/null +++ b/test/plugin_functional/plugins/usage_collection/server/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { UsageCollectionTestPlugin } from './plugin'; +export const plugin = () => new UsageCollectionTestPlugin(); diff --git a/test/plugin_functional/plugins/usage_collection/server/plugin.ts b/test/plugin_functional/plugins/usage_collection/server/plugin.ts new file mode 100644 index 00000000000000..523fbcfe058dce --- /dev/null +++ b/test/plugin_functional/plugins/usage_collection/server/plugin.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Plugin, CoreSetup } from 'kibana/server'; +import { + UsageCollectionSetup, + UsageCounter, +} from '../../../../../src/plugins/usage_collection/server'; +import { registerRoutes } from './routes'; + +export interface TestPluginDepsSetup { + usageCollection: UsageCollectionSetup; +} + +export class UsageCollectionTestPlugin implements Plugin { + private usageCounter?: UsageCounter; + + public setup(core: CoreSetup, { usageCollection }: TestPluginDepsSetup) { + const usageCounter = usageCollection.createUsageCounter('usageCollectionTestPlugin'); + + registerRoutes(core.http, usageCounter); + usageCounter.incrementCounter({ + counterName: 'duringSetup', + incrementBy: 10, + }); + usageCounter.incrementCounter({ counterName: 'duringSetup' }); + this.usageCounter = usageCounter; + } + + public start() { + if (!this.usageCounter) { + throw new Error('this.usageCounter is expected to be defined during setup.'); + } + this.usageCounter.incrementCounter({ counterName: 'duringStart' }); + } + + public stop() {} +} diff --git a/test/plugin_functional/plugins/usage_collection/server/routes.ts b/test/plugin_functional/plugins/usage_collection/server/routes.ts new file mode 100644 index 00000000000000..e67e454512779e --- /dev/null +++ b/test/plugin_functional/plugins/usage_collection/server/routes.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { HttpServiceSetup } from 'kibana/server'; +import { UsageCounter } from '../../../../../src/plugins/usage_collection/server'; + +export function registerRoutes(http: HttpServiceSetup, usageCounter: UsageCounter) { + const router = http.createRouter(); + router.get( + { + path: '/api/usage_collection_test_plugin', + validate: false, + }, + async (context, req, res) => { + usageCounter.incrementCounter({ counterName: 'routeAccessed' }); + return res.ok(); + } + ); +} diff --git a/test/plugin_functional/plugins/usage_collection/tsconfig.json b/test/plugin_functional/plugins/usage_collection/tsconfig.json new file mode 100644 index 00000000000000..3d9d8ca9451d41 --- /dev/null +++ b/test/plugin_functional/plugins/usage_collection/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "server/**/*.ts", + "../../../../typings/**/*", + ], + "exclude": [], + "references": [ + { "path": "../../../../src/core/tsconfig.json" } + ] +} diff --git a/test/plugin_functional/test_suites/usage_collection/index.ts b/test/plugin_functional/test_suites/usage_collection/index.ts new file mode 100644 index 00000000000000..201b7b04ff2222 --- /dev/null +++ b/test/plugin_functional/test_suites/usage_collection/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ loadTestFile }: PluginFunctionalProviderContext) { + describe('usage collection', function () { + loadTestFile(require.resolve('./usage_counters')); + }); +} diff --git a/test/plugin_functional/test_suites/usage_collection/usage_counters.ts b/test/plugin_functional/test_suites/usage_collection/usage_counters.ts new file mode 100644 index 00000000000000..f1591165b8d650 --- /dev/null +++ b/test/plugin_functional/test_suites/usage_collection/usage_counters.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; +import { + UsageCountersSavedObject, + serializeCounterKey, +} from '../../../../src/plugins/usage_collection/server/usage_counters'; + +export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + + async function getSavedObjectCounters() { + // wait until ES indexes the counter SavedObject; + await new Promise((res) => setTimeout(res, 7 * 1000)); + + return await supertest + .get('/api/saved_objects/_find?type=usage-counters') + .set('kbn-xsrf', 'true') + .expect(200) + .then(({ body }) => { + expect(body.total).to.above(1); + return (body.saved_objects as UsageCountersSavedObject[]).reduce((acc, savedObj) => { + const { count, counterName, domainId } = savedObj.attributes; + if (domainId === 'usageCollectionTestPlugin') { + acc[counterName] = count; + } + + return acc; + }, {} as Record); + }); + } + + describe('Usage Counters service', () => { + before(async () => { + const key = serializeCounterKey({ + counterName: 'routeAccessed', + counterType: 'count', + domainId: 'usageCollectionTestPlugin', + date: Date.now(), + }); + + await supertest.delete(`/api/saved_objects/usage-counters/${key}`).set('kbn-xsrf', 'true'); + }); + + it('stores usage counters sent during start and setup', async () => { + const { duringSetup, duringStart, routeAccessed } = await getSavedObjectCounters(); + + expect(duringSetup).to.be(11); + expect(duringStart).to.be(1); + expect(routeAccessed).to.be(undefined); + }); + + it('stores usage counters triggered by runtime activities', async () => { + await supertest.get('/api/usage_collection_test_plugin').set('kbn-xsrf', 'true').expect(200); + + const { routeAccessed } = await getSavedObjectCounters(); + expect(routeAccessed).to.be(1); + }); + }); +} From 3b7ef07eca9ed07c0823c3a73905e2f3dd74e780 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Wed, 14 Apr 2021 15:32:44 +0300 Subject: [PATCH 68/90] [TSVB] Field validation should not be performed on string indexes. (#97052) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../server/lib/vis_data/get_interval_and_timefield.ts | 4 +++- .../vis_data/request_processors/annotations/date_histogram.js | 4 +++- .../lib/vis_data/request_processors/annotations/query.js | 4 +++- .../lib/vis_data/request_processors/annotations/top_hits.js | 4 +++- .../lib/vis_data/request_processors/series/date_histogram.js | 2 +- .../lib/vis_data/request_processors/table/date_histogram.js | 2 +- 6 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.ts index e3d0cec1a6939a..1d35a9fd28e618 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.ts @@ -19,7 +19,9 @@ export function getIntervalAndTimefield( (series.override_index_pattern ? series.series_time_field : panel.time_field) || index.indexPattern?.timeFieldName; - validateField(timeField!, index); + if (panel.use_kibana_indexes) { + validateField(timeField!, index); + } let interval = panel.interval; let maxBars = panel.max_bars; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js index 48b35d0db50861..bfb3e0f218460b 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js @@ -27,7 +27,9 @@ export function dateHistogram( const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); const timeField = annotation.time_field || annotationIndex.indexPattern?.timeFieldName || ''; - validateField(timeField, annotationIndex); + if (panel.use_kibana_indexes) { + validateField(timeField, annotationIndex); + } const { bucketSize, intervalString } = getBucketSize( req, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/query.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/query.js index 3be567dfe1f406..fcad23b9170a7a 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/query.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/query.js @@ -24,7 +24,9 @@ export function query( const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); const timeField = (annotation.time_field || annotationIndex.indexPattern?.timeFieldName) ?? ''; - validateField(timeField, annotationIndex); + if (panel.use_kibana_indexes) { + validateField(timeField, annotationIndex); + } const { bucketSize } = getBucketSize(req, 'auto', capabilities, barTargetUiSettings); const { from, to } = getTimerange(req); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/top_hits.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/top_hits.js index 447cfdbc8c6e4d..b85eb39c18ba63 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/top_hits.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/top_hits.js @@ -14,7 +14,9 @@ export function topHits(req, panel, annotation, esQueryConfig, annotationIndex) const fields = (annotation.fields && annotation.fields.split(/[,\s]+/)) || []; const timeField = annotation.time_field || annotationIndex.indexPattern?.timeFieldName || ''; - validateField(timeField, annotationIndex); + if (panel.use_kibana_indexes) { + validateField(timeField, annotationIndex); + } overwrite(doc, `aggs.${annotation.id}.aggs.hits.top_hits`, { sort: [ diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js index 41ed472c319366..29cf3f274dc24a 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js @@ -67,7 +67,7 @@ export function dateHistogram( intervalString, bucketSize, seriesId: series.id, - index: seriesIndex.indexPattern?.id, + index: panel.use_kibana_indexes ? seriesIndex.indexPattern?.id : undefined, }); return next(doc); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js index 4840e625383ca3..f0989cf0fa08b4 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js @@ -23,7 +23,7 @@ export function dateHistogram(req, panel, esQueryConfig, seriesIndex, capabiliti const meta = { timeField, - index: seriesIndex.indexPattern?.id, + index: panel.use_kibana_indexes ? seriesIndex.indexPattern?.id : undefined, }; const getDateHistogramForLastBucketMode = () => { From bcc1acb1ddbb9c46333557dc15e8c41b5cd45a8a Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Wed, 14 Apr 2021 15:33:41 +0300 Subject: [PATCH 69/90] [TSVB][performance] remove visPayloadSchema.validate (#97091) * [TSVB][performance] remove visPayloadSchema.validate Part of: #97061 * Update vis.ts --- src/plugins/vis_type_timeseries/server/routes/vis.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/plugins/vis_type_timeseries/server/routes/vis.ts b/src/plugins/vis_type_timeseries/server/routes/vis.ts index 733face97cb4a5..b2f27ab3c48611 100644 --- a/src/plugins/vis_type_timeseries/server/routes/vis.ts +++ b/src/plugins/vis_type_timeseries/server/routes/vis.ts @@ -9,7 +9,6 @@ import { schema } from '@kbn/config-schema'; import { ensureNoUnsafeProperties } from '@kbn/std'; import { getVisData } from '../lib/get_vis_data'; -import { visPayloadSchema } from '../../common/vis_schema'; import { ROUTES } from '../../common/constants'; import { Framework } from '../plugin'; import type { VisTypeTimeseriesRouter } from '../types'; @@ -34,14 +33,6 @@ export const visDataRoutes = (router: VisTypeTimeseriesRouter, framework: Framew }); } - try { - visPayloadSchema.validate(request.body); - } catch (error) { - framework.logger.debug( - `Request validation error: ${error.message}. This most likely means your TSVB visualization contains outdated configuration. You can report this problem under https://github.com/elastic/kibana/issues/new?template=Bug_report.md` - ); - } - const results = await getVisData(requestContext, request, framework); return response.ok({ body: results }); } From 1630c14a152b2b9737757c80c144e509bd7cad1c Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Wed, 14 Apr 2021 08:14:12 -0500 Subject: [PATCH 70/90] [Workplace Search] Remove shadows from Source overview panels (#97055) --- .../content_sources/components/overview.tsx | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx index dc925e21460da1..a5a2d8ab73d94d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx @@ -230,7 +230,12 @@ export const Overview: React.FC = () => { {groups.map((group, index) => ( - + {group.name} @@ -248,7 +253,7 @@ export const Overview: React.FC = () => {

{CONFIGURATION_TITLE}

- + {details.map((detail, index) => ( {

{DOCUMENT_PERMISSIONS_TITLE}

- + @@ -298,7 +303,7 @@ export const Overview: React.FC = () => {

{DOCUMENT_PERMISSIONS_TITLE}

- + @@ -329,7 +334,7 @@ export const Overview: React.FC = () => { ); const sourceStatus = ( - +
{STATUS_HEADER} @@ -353,7 +358,7 @@ export const Overview: React.FC = () => { ); const permissionsStatus = ( - +
{STATUS_HEADING} @@ -389,7 +394,7 @@ export const Overview: React.FC = () => { ); const credentials = ( - +
{CREDENTIALS_TITLE} @@ -409,7 +414,7 @@ export const Overview: React.FC = () => { title: string; children: React.ReactNode; }) => ( - +
{DOCUMENTATION_LINK_TITLE} @@ -424,7 +429,7 @@ export const Overview: React.FC = () => { ); const documentPermssionsLicenseLocked = ( - + From 366a537d37467dca4af4fac75d4a1a0f19a6e79d Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Wed, 14 Apr 2021 08:25:18 -0500 Subject: [PATCH 71/90] [Workplace Search] Add breadcrumbs to Role mappings (#97051) * Update Workplace Search nav to align with App Search * Add constants to shared * [App Search] Use shared constants * [Workplace Search] Add breadcrumbs to Role mappings * Enable shouldShowActiveForSubroutes --- .../app_search/components/role_mappings/constants.ts | 9 --------- .../components/role_mappings/role_mapping.tsx | 8 +++++--- .../applications/shared/role_mapping/constants.ts | 10 ++++++++++ .../workplace_search/components/layout/nav.tsx | 4 +++- .../public/applications/workplace_search/constants.ts | 2 +- .../views/role_mappings/role_mapping.tsx | 10 +++++++++- .../views/role_mappings/role_mappings.tsx | 2 ++ 7 files changed, 30 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts index 1fed750a86dc4f..2f9ff707f96317 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/constants.ts @@ -18,15 +18,6 @@ export const UPDATE_ROLE_MAPPING = i18n.translate( { defaultMessage: 'Update role mapping' } ); -export const ADD_ROLE_MAPPING_TITLE = i18n.translate( - 'xpack.enterpriseSearch.appSearch.roleMapping.newRoleMappingTitle', - { defaultMessage: 'Add role mapping' } -); -export const MANAGE_ROLE_MAPPING_TITLE = i18n.translate( - 'xpack.enterpriseSearch.appSearch.roleMapping.manageRoleMappingTitle', - { defaultMessage: 'Manage role mapping' } -); - export const EMPTY_ROLE_MAPPINGS_BODY = i18n.translate( 'xpack.enterpriseSearch.appSearch.roleMapping.emptyRoleMappingsBody', { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx index 47c0eb2483ec12..610ceae8856f24 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx @@ -33,7 +33,11 @@ import { DeleteMappingCallout, RoleSelector, } from '../../../shared/role_mapping'; -import { ROLE_MAPPINGS_TITLE } from '../../../shared/role_mapping/constants'; +import { + ROLE_MAPPINGS_TITLE, + ADD_ROLE_MAPPING_TITLE, + MANAGE_ROLE_MAPPING_TITLE, +} from '../../../shared/role_mapping/constants'; import { AppLogic } from '../../app_logic'; import { roleHasScopedEngines } from '../../utils/role/has_scoped_engines'; @@ -42,8 +46,6 @@ import { Engine } from '../engine/types'; import { SAVE_ROLE_MAPPING, UPDATE_ROLE_MAPPING, - ADD_ROLE_MAPPING_TITLE, - MANAGE_ROLE_MAPPING_TITLE, ADVANCED_ROLE_TYPES, STANDARD_ROLE_TYPES, ADVANCED_ROLE_SELECTORS_TITLE, diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts index 8abab6d060a96e..a172fbae18d8fe 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts @@ -108,6 +108,16 @@ export const ROLE_MAPPINGS_TITLE = i18n.translate( } ); +export const ADD_ROLE_MAPPING_TITLE = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.newRoleMappingTitle', + { defaultMessage: 'Add role mapping' } +); + +export const MANAGE_ROLE_MAPPING_TITLE = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.manageRoleMappingTitle', + { defaultMessage: 'Manage role mapping' } +); + export const EMPTY_ROLE_MAPPINGS_TITLE = i18n.translate( 'xpack.enterpriseSearch.roleMapping.emptyRoleMappingsTitle', { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx index f2edc04a5661c4..51cdcc688e682b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx @@ -42,7 +42,9 @@ export const WorkplaceSearchNav: React.FC = ({ {NAV.GROUPS} - {NAV.ROLE_MAPPINGS} + + {NAV.ROLE_MAPPINGS} + {NAV.SECURITY} {NAV.SETTINGS} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index d7716735067617..9f758cacdfce35 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -40,7 +40,7 @@ export const NAV = { defaultMessage: 'Content', }), ROLE_MAPPINGS: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.roleMappings', { - defaultMessage: 'Role Mappings', + defaultMessage: 'Users & roles', }), SECURITY: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.security', { defaultMessage: 'Security', diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.tsx index d69e94b20444ea..fb366883601a6f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.tsx @@ -24,13 +24,19 @@ import { import { i18n } from '@kbn/i18n'; import { FlashMessages } from '../../../shared/flash_messages'; +import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { Loading } from '../../../shared/loading'; import { AttributeSelector, DeleteMappingCallout, RoleSelector, } from '../../../shared/role_mapping'; -import { ROLE_LABEL } from '../../../shared/role_mapping/constants'; +import { + ROLE_LABEL, + ROLE_MAPPINGS_TITLE, + ADD_ROLE_MAPPING_TITLE, + MANAGE_ROLE_MAPPING_TITLE, +} from '../../../shared/role_mapping/constants'; import { ViewContentHeader } from '../../components/shared/view_content_header'; import { Role } from '../../types'; @@ -105,6 +111,7 @@ export const RoleMapping: React.FC = ({ isNew }) => { const hasGroupAssignment = selectedGroups.size > 0 || includeInAllGroups; + const TITLE = isNew ? ADD_ROLE_MAPPING_TITLE : MANAGE_ROLE_MAPPING_TITLE; const SAVE_ROLE_MAPPING_LABEL = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.roleMapping.saveRoleMappingButtonMessage', { @@ -121,6 +128,7 @@ export const RoleMapping: React.FC = ({ isNew }) => { return ( <> +
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx index 0e3533d48a5a97..9ec0dfc0acefc4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx @@ -12,6 +12,7 @@ import { useActions, useValues } from 'kea'; import { EuiEmptyPrompt, EuiPanel } from '@elastic/eui'; import { FlashMessages } from '../../../shared/flash_messages'; +import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { Loading } from '../../../shared/loading'; import { AddRoleMappingButton, RoleMappingsTable } from '../../../shared/role_mapping'; import { @@ -61,6 +62,7 @@ export const RoleMappings: React.FC = () => { return ( <> +
From e36650de70b38d6cd6c26c24e8bdd67834327ed4 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Wed, 14 Apr 2021 14:38:10 +0100 Subject: [PATCH 72/90] chore(NA): moving @kbn/config-schema into bazel (#96273) * chore(NA): moving @kbn/config-schema into bazel * chore(NA): correctly format packages for the new bazel standards * chore(NA): correctly maps srcs into source_files * chore(NA): remove config-schema dep from legacy built packages package.jsons * chore(NA): include kbn/config-schema in the list of bazel packages to be built * chore(NA): change import to fix typechecking * chore(NA): remove dependency on new package built by bazel * chore(NA): be more explicit about incremental setting * chore(NA): include pretty in the args for ts_project rule * docs(NA): include package migration completion in the developer getting started Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../monorepo-packages.asciidoc | 2 +- package.json | 2 +- packages/BUILD.bazel | 3 +- packages/kbn-cli-dev-mode/package.json | 1 - packages/kbn-config-schema/BUILD.bazel | 86 +++++++++++++++++++ packages/kbn-config-schema/package.json | 10 +-- packages/kbn-config-schema/tsconfig.json | 8 +- packages/kbn-config/package.json | 1 - packages/kbn-legacy-logging/package.json | 3 +- packages/kbn-server-http-tools/package.json | 1 - packages/kbn-utils/package.json | 3 - src/core/server/server.api.md | 2 +- .../vis_type_timeseries/common/vis_schema.ts | 2 +- x-pack/package.json | 1 - yarn.lock | 2 +- 15 files changed, 101 insertions(+), 26 deletions(-) create mode 100644 packages/kbn-config-schema/BUILD.bazel diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index 655a491f8b3ca5..88a142e5b53c08 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -63,5 +63,5 @@ yarn kbn watch-bazel - @elastic/datemath - @kbn/apm-utils - +- @kbn/config-schema diff --git a/package.json b/package.json index c1f2a3b3cf1323..1d31aa627129c1 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,7 @@ "@kbn/apm-config-loader": "link:packages/kbn-apm-config-loader", "@kbn/apm-utils": "link:bazel-bin/packages/kbn-apm-utils/npm_module", "@kbn/config": "link:packages/kbn-config", - "@kbn/config-schema": "link:packages/kbn-config-schema", + "@kbn/config-schema": "link:bazel-bin/packages/kbn-config-schema/npm_module", "@kbn/crypto": "link:packages/kbn-crypto", "@kbn/i18n": "link:packages/kbn-i18n", "@kbn/interpreter": "link:packages/kbn-interpreter", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 3944c2356badc7..aa66c96764718f 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -4,6 +4,7 @@ filegroup( name = "build", srcs = [ "//packages/elastic-datemath:build", - "//packages/kbn-apm-utils:build" + "//packages/kbn-apm-utils:build", + "//packages/kbn-config-schema:build" ], ) diff --git a/packages/kbn-cli-dev-mode/package.json b/packages/kbn-cli-dev-mode/package.json index 2ee9831e960842..1ea319ef3601c9 100644 --- a/packages/kbn-cli-dev-mode/package.json +++ b/packages/kbn-cli-dev-mode/package.json @@ -15,7 +15,6 @@ }, "dependencies": { "@kbn/config": "link:../kbn-config", - "@kbn/config-schema": "link:../kbn-config-schema", "@kbn/logging": "link:../kbn-logging", "@kbn/server-http-tools": "link:../kbn-server-http-tools", "@kbn/optimizer": "link:../kbn-optimizer", diff --git a/packages/kbn-config-schema/BUILD.bazel b/packages/kbn-config-schema/BUILD.bazel new file mode 100644 index 00000000000000..5dcbd9e5a802a2 --- /dev/null +++ b/packages/kbn-config-schema/BUILD.bazel @@ -0,0 +1,86 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-config-schema" +PKG_REQUIRE_NAME = "@kbn/config-schema" + +SOURCE_FILES = glob([ + "src/**/*.ts", + "types/joi.d.ts" +]) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md", +] + +SRC_DEPS = [ + "@npm//joi", + "@npm//lodash", + "@npm//moment", + "@npm//tsd", + "@npm//type-detect", +] + +TYPES_DEPS = [ + "@npm//@types/jest", + "@npm//@types/joi", + "@npm//@types/lodash", + "@npm//@types/node", + "@npm//@types/type-detect", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_map = True, + incremental = True, + out_dir = "target", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = [], + deps = [":tsc"] + DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + srcs = NPM_MODULE_EXTRA_FILES, + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-config-schema/package.json b/packages/kbn-config-schema/package.json index a47dee88db5887..85b52f5d75533d 100644 --- a/packages/kbn-config-schema/package.json +++ b/packages/kbn-config-schema/package.json @@ -1,12 +1,8 @@ { "name": "@kbn/config-schema", - "main": "./target/out/index.js", - "types": "./target/types/index.d.ts", + "main": "./target/index.js", + "types": "./target/index.d.ts", "version": "1.0.0", "license": "SSPL-1.0 OR Elastic License 2.0", - "private": true, - "scripts": { - "build": "../../node_modules/.bin/tsc", - "kbn:bootstrap": "yarn build" - } + "private": true } \ No newline at end of file diff --git a/packages/kbn-config-schema/tsconfig.json b/packages/kbn-config-schema/tsconfig.json index d33683acded162..5490f37a943fc9 100644 --- a/packages/kbn-config-schema/tsconfig.json +++ b/packages/kbn-config-schema/tsconfig.json @@ -1,14 +1,14 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "incremental": false, - "outDir": "./target/out", - "declarationDir": "./target/types", - "stripInternal": true, "declaration": true, "declarationMap": true, + "incremental": true, + "outDir": "target", + "rootDir": "src", "sourceMap": true, "sourceRoot": "../../../../../packages/kbn-config-schema/src", + "stripInternal": true, "types": [ "jest", "node" diff --git a/packages/kbn-config/package.json b/packages/kbn-config/package.json index e71175034787aa..8093b6ac0d211d 100644 --- a/packages/kbn-config/package.json +++ b/packages/kbn-config/package.json @@ -11,7 +11,6 @@ }, "dependencies": { "@elastic/safer-lodash-set": "link:../elastic-safer-lodash-set", - "@kbn/config-schema": "link:../kbn-config-schema", "@kbn/logging": "link:../kbn-logging", "@kbn/std": "link:../kbn-std" }, diff --git a/packages/kbn-legacy-logging/package.json b/packages/kbn-legacy-logging/package.json index 96edeccad6658a..9450fd39607ea9 100644 --- a/packages/kbn-legacy-logging/package.json +++ b/packages/kbn-legacy-logging/package.json @@ -11,7 +11,6 @@ "kbn:watch": "yarn build --watch" }, "dependencies": { - "@kbn/utils": "link:../kbn-utils", - "@kbn/config-schema": "link:../kbn-config-schema" + "@kbn/utils": "link:../kbn-utils" } } diff --git a/packages/kbn-server-http-tools/package.json b/packages/kbn-server-http-tools/package.json index 6c65a0dd6e475e..24f8f8d67dfd70 100644 --- a/packages/kbn-server-http-tools/package.json +++ b/packages/kbn-server-http-tools/package.json @@ -11,7 +11,6 @@ "kbn:watch": "yarn build --watch" }, "dependencies": { - "@kbn/config-schema": "link:../kbn-config-schema", "@kbn/crypto": "link:../kbn-crypto", "@kbn/std": "link:../kbn-std" }, diff --git a/packages/kbn-utils/package.json b/packages/kbn-utils/package.json index b6bb7759c40efe..2c3c0c11b65ab8 100644 --- a/packages/kbn-utils/package.json +++ b/packages/kbn-utils/package.json @@ -9,8 +9,5 @@ "build": "rm -rf target && ../../node_modules/.bin/tsc", "kbn:bootstrap": "yarn build", "kbn:watch": "yarn build --watch" - }, - "dependencies": { - "@kbn/config-schema": "link:../kbn-config-schema" } } \ No newline at end of file diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 53b2eb86104183..05af684053f391 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -363,7 +363,7 @@ export const config: { healthCheck: import("@kbn/config-schema").ObjectType<{ delay: Type; }>; - ignoreVersionMismatch: import("@kbn/config-schema/target/types/types").ConditionalType; + ignoreVersionMismatch: import("@kbn/config-schema/target/types").ConditionalType; }>; }; logging: { diff --git a/src/plugins/vis_type_timeseries/common/vis_schema.ts b/src/plugins/vis_type_timeseries/common/vis_schema.ts index 9fb7644b0fd169..d31fed4639ffe2 100644 --- a/src/plugins/vis_type_timeseries/common/vis_schema.ts +++ b/src/plugins/vis_type_timeseries/common/vis_schema.ts @@ -7,7 +7,7 @@ */ import { schema } from '@kbn/config-schema'; -import { TypeOptions } from '@kbn/config-schema/target/types/types'; +import { TypeOptions } from '@kbn/config-schema/target/types'; const stringOptionalNullable = schema.maybe(schema.nullable(schema.string())); const stringOptional = schema.maybe(schema.string()); diff --git a/x-pack/package.json b/x-pack/package.json index 9e963881450380..36a6d120d946bd 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -38,7 +38,6 @@ }, "dependencies": { "@elastic/safer-lodash-set": "link:../packages/elastic-safer-lodash-set", - "@kbn/config-schema": "link:../packages/kbn-config-schema", "@kbn/i18n": "link:../packages/kbn-i18n", "@kbn/interpreter": "link:../packages/kbn-interpreter", "@kbn/ui-framework": "link:../packages/kbn-ui-framework" diff --git a/yarn.lock b/yarn.lock index 693da02fddfdf2..4f20e0122d4708 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2632,7 +2632,7 @@ version "0.0.0" uid "" -"@kbn/config-schema@link:packages/kbn-config-schema": +"@kbn/config-schema@link:bazel-bin/packages/kbn-config-schema/npm_module": version "0.0.0" uid "" From b401cbb3ebc86343b12d45d34c8e122f0d38117d Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Wed, 14 Apr 2021 09:51:47 -0400 Subject: [PATCH 73/90] export DomainDeprecationDetails type from public + fix typo (#96885) --- ...public.domaindeprecationdetails.domainid.md | 11 +++++++++++ ...gin-core-public.domaindeprecationdetails.md | 18 ++++++++++++++++++ .../core/public/kibana-plugin-core-public.md | 1 + src/core/public/index.ts | 7 ++++++- src/core/public/public.api.md | 10 +++++++++- .../core_plugin_deprecations/server/config.ts | 2 +- .../test_suites/core/deprecations.ts | 2 +- 7 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-core-public.domaindeprecationdetails.domainid.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.domaindeprecationdetails.md diff --git a/docs/development/core/public/kibana-plugin-core-public.domaindeprecationdetails.domainid.md b/docs/development/core/public/kibana-plugin-core-public.domaindeprecationdetails.domainid.md new file mode 100644 index 00000000000000..b6d1f9386be8fb --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.domaindeprecationdetails.domainid.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [DomainDeprecationDetails](./kibana-plugin-core-public.domaindeprecationdetails.md) > [domainId](./kibana-plugin-core-public.domaindeprecationdetails.domainid.md) + +## DomainDeprecationDetails.domainId property + +Signature: + +```typescript +domainId: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.domaindeprecationdetails.md b/docs/development/core/public/kibana-plugin-core-public.domaindeprecationdetails.md new file mode 100644 index 00000000000000..93d715a11c5036 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.domaindeprecationdetails.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [DomainDeprecationDetails](./kibana-plugin-core-public.domaindeprecationdetails.md) + +## DomainDeprecationDetails interface + +Signature: + +```typescript +export interface DomainDeprecationDetails extends DeprecationsDetails +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [domainId](./kibana-plugin-core-public.domaindeprecationdetails.domainid.md) | string | | + diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index 32f17d5488f66c..39e554f5492ac2 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -61,6 +61,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [CoreStart](./kibana-plugin-core-public.corestart.md) | Core services exposed to the Plugin start lifecycle | | [DeprecationsServiceStart](./kibana-plugin-core-public.deprecationsservicestart.md) | DeprecationsService provides methods to fetch domain deprecation details from the Kibana server. | | [DocLinksStart](./kibana-plugin-core-public.doclinksstart.md) | | +| [DomainDeprecationDetails](./kibana-plugin-core-public.domaindeprecationdetails.md) | | | [ErrorToastOptions](./kibana-plugin-core-public.errortoastoptions.md) | Options available for [IToasts](./kibana-plugin-core-public.itoasts.md) error APIs. | | [FatalErrorInfo](./kibana-plugin-core-public.fatalerrorinfo.md) | Represents the message and stack of a fatal Error | | [FatalErrorsSetup](./kibana-plugin-core-public.fatalerrorssetup.md) | FatalErrors stop the Kibana Public Core and displays a fatal error screen with details about the Kibana build and the error. | diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 750f2e27dc9504..ca432d6b8269fa 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -67,7 +67,12 @@ import { DocLinksStart } from './doc_links'; import { SavedObjectsStart } from './saved_objects'; import { DeprecationsServiceStart } from './deprecations'; -export type { PackageInfo, EnvironmentMode, IExternalUrlPolicy } from '../server/types'; +export type { + PackageInfo, + EnvironmentMode, + IExternalUrlPolicy, + DomainDeprecationDetails, +} from '../server/types'; export type { CoreContext, CoreSystem } from './core_system'; export { DEFAULT_APP_CATEGORIES } from '../utils'; export type { diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 3f4de7fccac72b..88e4b0448a7be8 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -476,7 +476,6 @@ export const DEFAULT_APP_CATEGORIES: Record; // @public export interface DeprecationsServiceStart { - // Warning: (ae-forgotten-export) The symbol "DomainDeprecationDetails" needs to be exported by the entry point index.d.ts getAllDeprecations: () => Promise; getDeprecations: (domainId: string) => Promise; isDeprecationResolvable: (details: DomainDeprecationDetails) => boolean; @@ -658,6 +657,15 @@ export interface DocLinksStart { }; } +// Warning: (ae-forgotten-export) The symbol "DeprecationsDetails" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "DomainDeprecationDetails" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface DomainDeprecationDetails extends DeprecationsDetails { + // (undocumented) + domainId: string; +} + export { EnvironmentMode } // @public diff --git a/test/plugin_functional/plugins/core_plugin_deprecations/server/config.ts b/test/plugin_functional/plugins/core_plugin_deprecations/server/config.ts index db4288d26a3d7b..e051c39f681504 100644 --- a/test/plugin_functional/plugins/core_plugin_deprecations/server/config.ts +++ b/test/plugin_functional/plugins/core_plugin_deprecations/server/config.ts @@ -24,7 +24,7 @@ const configSecretDeprecation: ConfigDeprecation = (settings, fromPath, addDepre addDeprecation({ documentationUrl: 'config-secret-doc-url', message: - 'Kibana plugin funcitonal tests will no longer allow corePluginDeprecations.secret ' + + 'Kibana plugin functional tests will no longer allow corePluginDeprecations.secret ' + 'config to be set to anything except 42.', }); } diff --git a/test/plugin_functional/test_suites/core/deprecations.ts b/test/plugin_functional/test_suites/core/deprecations.ts index c44781ab284c66..a78527d0d82e25 100644 --- a/test/plugin_functional/test_suites/core/deprecations.ts +++ b/test/plugin_functional/test_suites/core/deprecations.ts @@ -42,7 +42,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide { level: 'critical', message: - 'Kibana plugin funcitonal tests will no longer allow corePluginDeprecations.secret config to be set to anything except 42.', + 'Kibana plugin functional tests will no longer allow corePluginDeprecations.secret config to be set to anything except 42.', correctiveActions: {}, documentationUrl: 'config-secret-doc-url', domainId: 'corePluginDeprecations', From e2eeb4461339104f3187b8c0c837325ffe984c92 Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Wed, 14 Apr 2021 16:36:36 +0200 Subject: [PATCH 74/90] Bump hosted-git-info from 2.5.0/3.0.7 to 2.8.9/3.0.8 (#96987) --- packages/kbn-pm/dist/index.js | 156 +++++++++++++++++++++++++--------- yarn.lock | 12 +-- 2 files changed, 124 insertions(+), 44 deletions(-) diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index af199fbbc27c29..e6cdd52686656a 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -29754,15 +29754,14 @@ var gitHosts = __webpack_require__(284) var GitHost = module.exports = __webpack_require__(285) var protocolToRepresentationMap = { - 'git+ssh': 'sshurl', - 'git+https': 'https', - 'ssh': 'sshurl', - 'git': 'git' + 'git+ssh:': 'sshurl', + 'git+https:': 'https', + 'ssh:': 'sshurl', + 'git:': 'git' } function protocolToRepresentation (protocol) { - if (protocol.substr(-1) === ':') protocol = protocol.slice(0, -1) - return protocolToRepresentationMap[protocol] || protocol + return protocolToRepresentationMap[protocol] || protocol.slice(0, -1) } var authProtocols = { @@ -29776,6 +29775,7 @@ var authProtocols = { var cache = {} module.exports.fromUrl = function (giturl, opts) { + if (typeof giturl !== 'string') return var key = giturl + JSON.stringify(opts || {}) if (!(key in cache)) { @@ -29791,13 +29791,13 @@ function fromUrl (giturl, opts) { isGitHubShorthand(giturl) ? 'github:' + giturl : giturl ) var parsed = parseGitUrl(url) - var shortcutMatch = url.match(new RegExp('^([^:]+):(?:(?:[^@:]+(?:[^@]+)?@)?([^/]*))[/](.+?)(?:[.]git)?($|#)')) + var shortcutMatch = url.match(/^([^:]+):(?:[^@]+@)?(?:([^/]*)\/)?([^#]+)/) var matches = Object.keys(gitHosts).map(function (gitHostName) { try { var gitHostInfo = gitHosts[gitHostName] var auth = null if (parsed.auth && authProtocols[parsed.protocol]) { - auth = decodeURIComponent(parsed.auth) + auth = parsed.auth } var committish = parsed.hash ? decodeURIComponent(parsed.hash.substr(1)) : null var user = null @@ -29805,22 +29805,27 @@ function fromUrl (giturl, opts) { var defaultRepresentation = null if (shortcutMatch && shortcutMatch[1] === gitHostName) { user = shortcutMatch[2] && decodeURIComponent(shortcutMatch[2]) - project = decodeURIComponent(shortcutMatch[3]) + project = decodeURIComponent(shortcutMatch[3].replace(/\.git$/, '')) defaultRepresentation = 'shortcut' } else { - if (parsed.host !== gitHostInfo.domain) return + if (parsed.host && parsed.host !== gitHostInfo.domain && parsed.host.replace(/^www[.]/, '') !== gitHostInfo.domain) return if (!gitHostInfo.protocols_re.test(parsed.protocol)) return if (!parsed.path) return var pathmatch = gitHostInfo.pathmatch var matched = parsed.path.match(pathmatch) if (!matched) return - if (matched[1] != null) user = decodeURIComponent(matched[1].replace(/^:/, '')) - if (matched[2] != null) project = decodeURIComponent(matched[2]) + /* istanbul ignore else */ + if (matched[1] !== null && matched[1] !== undefined) { + user = decodeURIComponent(matched[1].replace(/^:/, '')) + } + project = decodeURIComponent(matched[2]) defaultRepresentation = protocolToRepresentation(parsed.protocol) } return new GitHost(gitHostName, user, auth, project, committish, defaultRepresentation, opts) } catch (ex) { - if (!(ex instanceof URIError)) throw ex + /* istanbul ignore else */ + if (ex instanceof URIError) { + } else throw ex } }).filter(function (gitHostInfo) { return gitHostInfo }) if (matches.length !== 1) return @@ -29850,9 +29855,31 @@ function fixupUnqualifiedGist (giturl) { } function parseGitUrl (giturl) { - if (typeof giturl !== 'string') giturl = '' + giturl var matched = giturl.match(/^([^@]+)@([^:/]+):[/]?((?:[^/]+[/])?[^/]+?)(?:[.]git)?(#.*)?$/) - if (!matched) return url.parse(giturl) + if (!matched) { + var legacy = url.parse(giturl) + // If we don't have url.URL, then sorry, this is just not fixable. + // This affects Node <= 6.12. + if (legacy.auth && typeof url.URL === 'function') { + // git urls can be in the form of scp-style/ssh-connect strings, like + // git+ssh://user@host.com:some/path, which the legacy url parser + // supports, but WhatWG url.URL class does not. However, the legacy + // parser de-urlencodes the username and password, so something like + // https://user%3An%40me:p%40ss%3Aword@x.com/ becomes + // https://user:n@me:p@ss:word@x.com/ which is all kinds of wrong. + // Pull off just the auth and host, so we dont' get the confusing + // scp-style URL, then pass that to the WhatWG parser to get the + // auth properly escaped. + var authmatch = giturl.match(/[^@]+@[^:/]+/) + /* istanbul ignore else - this should be impossible */ + if (authmatch) { + var whatwg = new url.URL(authmatch[0]) + legacy.auth = whatwg.username || '' + if (whatwg.password) legacy.auth += ':' + whatwg.password + } + } + return legacy + } return { protocol: 'git+ssh:', slashes: true, @@ -29894,7 +29921,7 @@ var gitHosts = module.exports = { 'filetemplate': 'https://{auth@}raw.githubusercontent.com/{user}/{project}/{committish}/{path}', 'bugstemplate': 'https://{domain}/{user}/{project}/issues', 'gittemplate': 'git://{auth@}{domain}/{user}/{project}.git{#committish}', - 'tarballtemplate': 'https://{domain}/{user}/{project}/archive/{committish}.tar.gz' + 'tarballtemplate': 'https://codeload.{domain}/{user}/{project}/tar.gz/{committish}' }, bitbucket: { 'protocols': [ 'git+ssh', 'git+https', 'ssh', 'https' ], @@ -29906,25 +29933,30 @@ var gitHosts = module.exports = { 'protocols': [ 'git+ssh', 'git+https', 'ssh', 'https' ], 'domain': 'gitlab.com', 'treepath': 'tree', - 'docstemplate': 'https://{domain}/{user}/{project}{/tree/committish}#README', 'bugstemplate': 'https://{domain}/{user}/{project}/issues', - 'tarballtemplate': 'https://{domain}/{user}/{project}/repository/archive.tar.gz?ref={committish}' + 'httpstemplate': 'git+https://{auth@}{domain}/{user}/{projectPath}.git{#committish}', + 'tarballtemplate': 'https://{domain}/{user}/{project}/repository/archive.tar.gz?ref={committish}', + 'pathmatch': /^[/]([^/]+)[/]((?!.*(\/-\/|\/repository\/archive\.tar\.gz\?=.*|\/repository\/[^/]+\/archive.tar.gz$)).*?)(?:[.]git|[/])?$/ }, gist: { 'protocols': [ 'git', 'git+ssh', 'git+https', 'ssh', 'https' ], 'domain': 'gist.github.com', - 'pathmatch': /^[/](?:([^/]+)[/])?([a-z0-9]+)(?:[.]git)?$/, + 'pathmatch': /^[/](?:([^/]+)[/])?([a-z0-9]{32,})(?:[.]git)?$/, 'filetemplate': 'https://gist.githubusercontent.com/{user}/{project}/raw{/committish}/{path}', 'bugstemplate': 'https://{domain}/{project}', 'gittemplate': 'git://{domain}/{project}.git{#committish}', 'sshtemplate': 'git@{domain}:/{project}.git{#committish}', 'sshurltemplate': 'git+ssh://git@{domain}/{project}.git{#committish}', 'browsetemplate': 'https://{domain}/{project}{/committish}', + 'browsefiletemplate': 'https://{domain}/{project}{/committish}{#path}', 'docstemplate': 'https://{domain}/{project}{/committish}', 'httpstemplate': 'git+https://{domain}/{project}.git{#committish}', 'shortcuttemplate': '{type}:{project}{#committish}', 'pathtemplate': '{project}{#committish}', - 'tarballtemplate': 'https://{domain}/{user}/{project}/archive/{committish}.tar.gz' + 'tarballtemplate': 'https://codeload.github.com/gist/{project}/tar.gz/{committish}', + 'hashformat': function (fragment) { + return 'file-' + formatHashFragment(fragment) + } } } @@ -29932,12 +29964,14 @@ var gitHostDefaults = { 'sshtemplate': 'git@{domain}:{user}/{project}.git{#committish}', 'sshurltemplate': 'git+ssh://git@{domain}/{user}/{project}.git{#committish}', 'browsetemplate': 'https://{domain}/{user}/{project}{/tree/committish}', + 'browsefiletemplate': 'https://{domain}/{user}/{project}/{treepath}/{committish}/{path}{#fragment}', 'docstemplate': 'https://{domain}/{user}/{project}{/tree/committish}#readme', 'httpstemplate': 'git+https://{auth@}{domain}/{user}/{project}.git{#committish}', 'filetemplate': 'https://{domain}/{user}/{project}/raw/{committish}/{path}', 'shortcuttemplate': '{type}:{user}/{project}{#committish}', 'pathtemplate': '{user}/{project}{#committish}', - 'pathmatch': /^[/]([^/]+)[/]([^/]+?)(?:[.]git|[/])?$/ + 'pathmatch': /^[/]([^/]+)[/]([^/]+?)(?:[.]git|[/])?$/, + 'hashformat': formatHashFragment } Object.keys(gitHosts).forEach(function (name) { @@ -29951,6 +29985,10 @@ Object.keys(gitHosts).forEach(function (name) { }).join('|') + '):$') }) +function formatHashFragment (fragment) { + return fragment.toLowerCase().replace(/^\W+|\/|\W+$/g, '').replace(/\W+/g, '-') +} + /***/ }), /* 285 */ @@ -29959,9 +29997,25 @@ Object.keys(gitHosts).forEach(function (name) { "use strict"; var gitHosts = __webpack_require__(284) -var extend = Object.assign || __webpack_require__(112)._extend +/* eslint-disable node/no-deprecated-api */ + +// copy-pasta util._extend from node's source, to avoid pulling +// the whole util module into peoples' webpack bundles. +/* istanbul ignore next */ +var extend = Object.assign || function _extend (target, source) { + // Don't do anything if source isn't an object + if (source === null || typeof source !== 'object') return target + + var keys = Object.keys(source) + var i = keys.length + while (i--) { + target[keys[i]] = source[keys[i]] + } + return target +} -var GitHost = module.exports = function (type, user, auth, project, committish, defaultRepresentation, opts) { +module.exports = GitHost +function GitHost (type, user, auth, project, committish, defaultRepresentation, opts) { var gitHostInfo = this gitHostInfo.type = type Object.keys(gitHosts[type]).forEach(function (key) { @@ -29974,7 +30028,6 @@ var GitHost = module.exports = function (type, user, auth, project, committish, gitHostInfo.default = defaultRepresentation gitHostInfo.opts = opts || {} } -GitHost.prototype = {} GitHost.prototype.hash = function () { return this.committish ? '#' + this.committish : '' @@ -29983,27 +30036,43 @@ GitHost.prototype.hash = function () { GitHost.prototype._fill = function (template, opts) { if (!template) return var vars = extend({}, opts) + vars.path = vars.path ? vars.path.replace(/^[/]+/g, '') : '' opts = extend(extend({}, this.opts), opts) var self = this Object.keys(this).forEach(function (key) { if (self[key] != null && vars[key] == null) vars[key] = self[key] }) var rawAuth = vars.auth - var rawComittish = vars.committish + var rawcommittish = vars.committish + var rawFragment = vars.fragment + var rawPath = vars.path + var rawProject = vars.project Object.keys(vars).forEach(function (key) { - vars[key] = encodeURIComponent(vars[key]) + var value = vars[key] + if ((key === 'path' || key === 'project') && typeof value === 'string') { + vars[key] = value.split('/').map(function (pathComponent) { + return encodeURIComponent(pathComponent) + }).join('/') + } else { + vars[key] = encodeURIComponent(value) + } }) vars['auth@'] = rawAuth ? rawAuth + '@' : '' + vars['#fragment'] = rawFragment ? '#' + this.hashformat(rawFragment) : '' + vars.fragment = vars.fragment ? vars.fragment : '' + vars['#path'] = rawPath ? '#' + this.hashformat(rawPath) : '' + vars['/path'] = vars.path ? '/' + vars.path : '' + vars.projectPath = rawProject.split('/').map(encodeURIComponent).join('/') if (opts.noCommittish) { vars['#committish'] = '' vars['/tree/committish'] = '' - vars['/comittish'] = '' - vars.comittish = '' + vars['/committish'] = '' + vars.committish = '' } else { - vars['#committish'] = rawComittish ? '#' + rawComittish : '' + vars['#committish'] = rawcommittish ? '#' + rawcommittish : '' vars['/tree/committish'] = vars.committish - ? '/' + vars.treepath + '/' + vars.committish - : '' + ? '/' + vars.treepath + '/' + vars.committish + : '' vars['/committish'] = vars.committish ? '/' + vars.committish : '' vars.committish = vars.committish || 'master' } @@ -30026,8 +30095,19 @@ GitHost.prototype.sshurl = function (opts) { return this._fill(this.sshurltemplate, opts) } -GitHost.prototype.browse = function (opts) { - return this._fill(this.browsetemplate, opts) +GitHost.prototype.browse = function (P, F, opts) { + if (typeof P === 'string') { + if (typeof F !== 'string') { + opts = F + F = null + } + return this._fill(this.browsefiletemplate, extend({ + fragment: F, + path: P + }, opts)) + } else { + return this._fill(this.browsetemplate, P) + } } GitHost.prototype.docs = function (opts) { @@ -30054,14 +30134,13 @@ GitHost.prototype.path = function (opts) { return this._fill(this.pathtemplate, opts) } -GitHost.prototype.tarball = function (opts) { +GitHost.prototype.tarball = function (opts_) { + var opts = extend({}, opts_, { noCommittish: false }) return this._fill(this.tarballtemplate, opts) } GitHost.prototype.file = function (P, opts) { - return this._fill(this.filetemplate, extend({ - path: P.replace(/^[/]+/g, '') - }, opts)) + return this._fill(this.filetemplate, extend({ path: P }, opts)) } GitHost.prototype.getDefaultRepresentation = function () { @@ -30069,7 +30148,8 @@ GitHost.prototype.getDefaultRepresentation = function () { } GitHost.prototype.toString = function (opts) { - return (this[this.default] || this.sshurl).call(this, opts) + if (this.default && typeof this[this.default] === 'function') return this[this.default](opts) + return this.sshurl(opts) } diff --git a/yarn.lock b/yarn.lock index 4f20e0122d4708..4a9d60a6af1943 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15841,14 +15841,14 @@ hooker@~0.2.3: integrity sha1-uDT3I8xKJCqmWWNFnfbZhMXT2Vk= hosted-git-info@^2.1.4: - version "2.5.0" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.5.0.tgz#6d60e34b3abbc8313062c3b798ef8d901a07af3c" - integrity sha512-pNgbURSuab90KbTqvRPsseaTxOJCZBD0a7t+haSN33piP9cCM4l0CqdzAif2hUqm716UovKB2ROmiabGAKVXyg== + version "2.8.9" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" + integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== hosted-git-info@^3.0.6: - version "3.0.7" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-3.0.7.tgz#a30727385ea85acfcee94e0aad9e368c792e036c" - integrity sha512-fWqc0IcuXs+BmE9orLDyVykAG9GJtGLGuZAAqgcckPgv5xad4AcXGIv8galtQvlwutxSlaMcdw7BUtq2EIvqCQ== + version "3.0.8" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-3.0.8.tgz#6e35d4cc87af2c5f816e4cb9ce350ba87a3f370d" + integrity sha512-aXpmwoOhRBrw6X3j0h5RloK4x1OzsxMPyxqIHyNfSe2pypkVTZFpEiRoSipPEPlMrh0HW/XsjkJ5WgnCirpNUw== dependencies: lru-cache "^6.0.0" From ad628878b11f1252769a511b24a7c76a3dbe046a Mon Sep 17 00:00:00 2001 From: Robert Oskamp Date: Wed, 14 Apr 2021 16:43:37 +0200 Subject: [PATCH 75/90] [ML] security_network module - fix type of defaultIndexPattern (#97096) This PR fixes the defaultIndexPattern type in the security_network module definition. --- .../modules/security_network/manifest.json | 6 +--- .../apis/ml/modules/get_module.ts | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/manifest.json index 55f07ab077d40b..2a2c0c202f66b3 100755 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/manifest.json @@ -4,11 +4,7 @@ "description": "Detect anomalous network activity in your ECS-compatible network logs.", "type": "network data", "logoFile": "logo.json", - "defaultIndexPattern": [ - "logs-*", - "filebeat-*", - "packetbeat-*" - ], + "defaultIndexPattern": "logs-*,filebeat-*,packetbeat-*", "query": { "bool": { "filter": [ diff --git a/x-pack/test/api_integration/apis/ml/modules/get_module.ts b/x-pack/test/api_integration/apis/ml/modules/get_module.ts index aade3723745489..59aa6102b54e21 100644 --- a/x-pack/test/api_integration/apis/ml/modules/get_module.ts +++ b/x-pack/test/api_integration/apis/ml/modules/get_module.ts @@ -11,6 +11,8 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; import { USER } from '../../../../functional/services/ml/security_common'; import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common_api'; +import { isPopulatedObject } from '../../../../../plugins/ml/common/util/object_utils'; + const moduleIds = [ 'apache_ecs', 'apm_jsbase', @@ -70,6 +72,32 @@ export default ({ getService }: FtrProviderContext) => { const rspBody = await executeGetModuleRequest(moduleId, USER.ML_POWERUSER, 200); expect(rspBody).to.be.an(Object); + expect(rspBody).to.have.property('id').a('string'); + expect(rspBody).to.have.property('title').a('string'); + expect(rspBody).to.have.property('description').a('string'); + expect(rspBody).to.have.property('type').a('string'); + if (isPopulatedObject(rspBody, ['logoFile'])) { + expect(rspBody).to.have.property('logoFile').a('string'); + } + if (isPopulatedObject(rspBody, ['logo'])) { + expect(rspBody).to.have.property('logo').an(Object); + } + if (isPopulatedObject(rspBody, ['defaultIndexPattern'])) { + expect(rspBody).to.have.property('defaultIndexPattern').a('string'); + } + if (isPopulatedObject(rspBody, ['query'])) { + expect(rspBody).to.have.property('query').an(Object); + } + if (isPopulatedObject(rspBody, ['jobs'])) { + expect(rspBody).to.have.property('jobs').an(Object); + } + if (isPopulatedObject(rspBody, ['datafeeds'])) { + expect(rspBody).to.have.property('datafeeds').an(Object); + } + if (isPopulatedObject(rspBody, ['kibana'])) { + expect(rspBody).to.have.property('kibana').an(Object); + } + expect(rspBody.id).to.eql(moduleId); }); } From 7c2cbd39c446256137d55b2d6c169cd9155d67a0 Mon Sep 17 00:00:00 2001 From: Marta Bondyra Date: Wed, 14 Apr 2021 17:18:45 +0200 Subject: [PATCH 76/90] [Lens] respect custom labels for fields in time series visualizations (#96937) --- .../indexpattern_suggestions.test.tsx | 7 +- .../operations/definitions.test.ts | 97 ++++++++++++++----- .../definitions/calculations/counter_rate.tsx | 16 ++- .../calculations/cumulative_sum.tsx | 14 ++- .../definitions/calculations/differences.tsx | 8 +- 5 files changed, 104 insertions(+), 38 deletions(-) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index e742b6ba62aff4..c4ebcab85e7227 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -70,7 +70,10 @@ const fieldsOne = [ aggregatable: true, searchable: true, }, - documentField, + { + ...documentField, + displayName: 'Records label', + }, ]; const fieldsTwo = [ @@ -2230,7 +2233,7 @@ describe('IndexPattern Data Source suggestions', () => { operation: { dataType: 'number', isBucketed: false, - label: 'Cumulative sum of Records', + label: 'Cumulative sum of Records label', scale: undefined, }, }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions.test.ts index 3add39cc5fb8a9..c131b16512823f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions.test.ts @@ -11,7 +11,10 @@ import { countOperation, counterRateOperation, movingAverageOperation, + cumulativeSumOperation, derivativeOperation, + AvgIndexPatternColumn, + DerivativeIndexPatternColumn, } from './definitions'; import { getFieldByNameFactory } from '../pure_helpers'; import { documentField } from '../document_field'; @@ -35,7 +38,7 @@ const indexPatternFields = [ }, { name: 'bytes', - displayName: 'bytes', + displayName: 'bytesLabel', type: 'number', aggregatable: true, searchable: true, @@ -98,6 +101,73 @@ const baseColumnArgs: { field: indexPattern.fields[2], }; +const layer: IndexPatternLayer = { + indexPatternId: '1', + columnOrder: ['date', 'metric', 'ref'], + columns: { + date: { + label: '', + customLabel: true, + dataType: 'date', + isBucketed: true, + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { interval: 'auto' }, + }, + metric: { + label: 'metricLabel', + customLabel: true, + dataType: 'number', + isBucketed: false, + operationType: 'average', + sourceField: 'bytes', + params: {}, + } as AvgIndexPatternColumn, + ref: { + label: '', + customLabel: true, + dataType: 'number', + isBucketed: false, + operationType: 'differences', + references: ['metric'], + } as DerivativeIndexPatternColumn, + }, +}; + +describe('labels', () => { + const calcColumnArgs = { + ...baseColumnArgs, + referenceIds: ['metric'], + layer, + previousColumn: layer.columns.metric, + }; + it('should use label of referenced operation to create label for derivative and moving average', () => { + expect(derivativeOperation.buildColumn(calcColumnArgs)).toEqual( + expect.objectContaining({ + label: 'Differences of metricLabel', + }) + ); + expect(movingAverageOperation.buildColumn(calcColumnArgs)).toEqual( + expect.objectContaining({ + label: 'Moving average of metricLabel', + }) + ); + }); + + it('should use displayName of a field for a label for counter rate and cumulative sum', () => { + expect(counterRateOperation.buildColumn(calcColumnArgs)).toEqual( + expect.objectContaining({ + label: 'Counter rate of bytesLabel per second', + }) + ); + expect(cumulativeSumOperation.buildColumn(calcColumnArgs)).toEqual( + expect.objectContaining({ + label: 'Cumulative sum of bytesLabel', + }) + ); + }); +}); + describe('time scale transition', () => { it('should carry over time scale and adjust label on operation from count to sum', () => { expect( @@ -107,7 +177,7 @@ describe('time scale transition', () => { ).toEqual( expect.objectContaining({ timeScale: 'h', - label: 'Sum of bytes per hour', + label: 'Sum of bytesLabel per hour', }) ); }); @@ -125,27 +195,6 @@ describe('time scale transition', () => { ); }); - it('should carry over time scale and adjust label on operation from sum to count', () => { - expect( - countOperation.buildColumn({ - ...baseColumnArgs, - previousColumn: { - label: 'Sum of bytes per hour', - timeScale: 'h', - dataType: 'number', - isBucketed: false, - operationType: 'sum', - sourceField: 'bytes', - }, - }) - ).toEqual( - expect.objectContaining({ - timeScale: 'h', - label: 'Count of records per hour', - }) - ); - }); - it('should not set time scale if it was not set previously', () => { expect( countOperation.buildColumn({ @@ -188,7 +237,7 @@ describe('time scale transition', () => { expect( sumOperation.onFieldChange( { - label: 'Sum of bytes per hour', + label: 'Sum of bytesLabel per hour', timeScale: 'h', dataType: 'number', isBucketed: false, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx index 331aa528e6d555..c57f70ba1b58b1 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx @@ -66,16 +66,26 @@ export const counterRateOperation: OperationDefinition< }, getDefaultLabel: (column, indexPattern, columns) => { const ref = columns[column.references[0]]; - return ofName(ref && 'sourceField' in ref ? ref.sourceField : undefined, column.timeScale); + return ofName( + ref && 'sourceField' in ref + ? indexPattern.getFieldByName(ref.sourceField)?.displayName + : undefined, + column.timeScale + ); }, toExpression: (layer, columnId) => { return dateBasedOperationToExpression(layer, columnId, 'lens_counter_rate'); }, - buildColumn: ({ referenceIds, previousColumn, layer }) => { + buildColumn: ({ referenceIds, previousColumn, layer, indexPattern }) => { const metric = layer.columns[referenceIds[0]]; const timeScale = previousColumn?.timeScale || DEFAULT_TIME_SCALE; return { - label: ofName(metric && 'sourceField' in metric ? metric.sourceField : undefined, timeScale), + label: ofName( + metric && 'sourceField' in metric + ? indexPattern.getFieldByName(metric.sourceField)?.displayName + : undefined, + timeScale + ), dataType: 'number', operationType: 'counter_rate', isBucketed: false, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx index 1664f3639b598a..7cec1fa0d4bbc9 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx @@ -64,15 +64,23 @@ export const cumulativeSumOperation: OperationDefinition< }, getDefaultLabel: (column, indexPattern, columns) => { const ref = columns[column.references[0]]; - return ofName(ref && 'sourceField' in ref ? ref.sourceField : undefined); + return ofName( + ref && 'sourceField' in ref + ? indexPattern.getFieldByName(ref.sourceField)?.displayName + : undefined + ); }, toExpression: (layer, columnId) => { return dateBasedOperationToExpression(layer, columnId, 'cumulative_sum'); }, - buildColumn: ({ referenceIds, previousColumn, layer }) => { + buildColumn: ({ referenceIds, previousColumn, layer, indexPattern }) => { const ref = layer.columns[referenceIds[0]]; return { - label: ofName(ref && 'sourceField' in ref ? ref.sourceField : undefined), + label: ofName( + ref && 'sourceField' in ref + ? indexPattern.getFieldByName(ref.sourceField)?.displayName + : undefined + ), dataType: 'number', operationType: 'cumulative_sum', isBucketed: false, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx index c50e9270eaac1e..bef3fbc2e48aed 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx @@ -66,8 +66,7 @@ export const derivativeOperation: OperationDefinition< } }, getDefaultLabel: (column, indexPattern, columns) => { - const ref = columns[column.references[0]]; - return ofName(ref && 'sourceField' in ref ? ref.sourceField : undefined, column.timeScale); + return ofName(columns[column.references[0]]?.label, column.timeScale); }, toExpression: (layer, columnId) => { return dateBasedOperationToExpression(layer, columnId, 'derivative'); @@ -75,10 +74,7 @@ export const derivativeOperation: OperationDefinition< buildColumn: ({ referenceIds, previousColumn, layer }) => { const ref = layer.columns[referenceIds[0]]; return { - label: ofName( - ref && 'sourceField' in ref ? ref.sourceField : undefined, - previousColumn?.timeScale - ), + label: ofName(ref?.label, previousColumn?.timeScale), dataType: 'number', operationType: OPERATION_NAME, isBucketed: false, From fe00b68aa213838cd3d710fb98aaa88e2f1d770b Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Wed, 14 Apr 2021 10:39:56 -0500 Subject: [PATCH 77/90] [Workplace Search] Update ID label to Source Identifier (#96970) --- .../workplace_search/views/content_sources/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts index 3398427a7111b3..32df63d0faba94 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts @@ -148,7 +148,7 @@ export const ACCESS_TOKEN_LABEL = i18n.translate( ); export const ID_LABEL = i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.id.label', { - defaultMessage: 'ID', + defaultMessage: 'Source Identifier', }); export const LEARN_CUSTOM_FEATURES_BUTTON = i18n.translate( From 7a070e893d09c0a3b2f4dd20c94a0a62a9837e3c Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Wed, 14 Apr 2021 10:43:08 -0500 Subject: [PATCH 78/90] [Fleet] Add preconfiguration to kibana config (#96588) --- .../resources/base/bin/kibana-docker | 2 + .../plugins/fleet/common/constants/index.ts | 1 + .../common/constants/preconfiguration.ts | 9 +++ x-pack/plugins/fleet/common/types/index.ts | 3 + .../common/types/rest_spec/ingest_setup.ts | 1 + .../fleet/public/applications/fleet/app.tsx | 11 ++- .../plugins/fleet/server/constants/index.ts | 1 + x-pack/plugins/fleet/server/index.ts | 4 ++ x-pack/plugins/fleet/server/plugin.ts | 2 + .../server/routes/setup/handlers.test.ts | 4 +- .../fleet/server/routes/setup/handlers.ts | 7 +- .../fleet/server/saved_objects/index.ts | 14 ++++ .../fleet/server/services/agent_policy.ts | 11 ++- .../fleet/server/services/package_policy.ts | 39 +++++++---- .../server/services/preconfiguration.test.ts | 49 +++++++------- .../fleet/server/services/preconfiguration.ts | 67 +++++++++++++------ x-pack/plugins/fleet/server/services/setup.ts | 49 ++++++++++---- 17 files changed, 199 insertions(+), 75 deletions(-) create mode 100644 x-pack/plugins/fleet/common/constants/preconfiguration.ts diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index 1ad15592889922..c65a3569448a38 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -200,6 +200,8 @@ kibana_vars=( xpack.fleet.agents.elasticsearch.host xpack.fleet.agents.kibana.host xpack.fleet.agents.tlsCheckDisabled + xpack.fleet.agentPolicies + xpack.fleet.packages xpack.fleet.registryUrl xpack.graph.canEditDrillDownUrls xpack.graph.enabled diff --git a/x-pack/plugins/fleet/common/constants/index.ts b/x-pack/plugins/fleet/common/constants/index.ts index 5598e63219776f..3704533e79b4ae 100644 --- a/x-pack/plugins/fleet/common/constants/index.ts +++ b/x-pack/plugins/fleet/common/constants/index.ts @@ -15,6 +15,7 @@ export * from './epm'; export * from './output'; export * from './enrollment_api_key'; export * from './settings'; +export * from './preconfiguration'; // TODO: This is the default `index.max_result_window` ES setting, which dictates // the maximum amount of results allowed to be returned from a search. It's possible diff --git a/x-pack/plugins/fleet/common/constants/preconfiguration.ts b/x-pack/plugins/fleet/common/constants/preconfiguration.ts new file mode 100644 index 00000000000000..376ba551b13593 --- /dev/null +++ b/x-pack/plugins/fleet/common/constants/preconfiguration.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE = + 'fleet-preconfiguration-deletion-record'; diff --git a/x-pack/plugins/fleet/common/types/index.ts b/x-pack/plugins/fleet/common/types/index.ts index 1984de79a6357e..cdea56448f3a2e 100644 --- a/x-pack/plugins/fleet/common/types/index.ts +++ b/x-pack/plugins/fleet/common/types/index.ts @@ -7,6 +7,7 @@ export * from './models'; export * from './rest_spec'; +import type { PreconfiguredAgentPolicy, PreconfiguredPackage } from './models/preconfiguration'; export interface FleetConfigType { enabled: boolean; @@ -32,6 +33,8 @@ export interface FleetConfigType { agentPolicyRolloutRateLimitIntervalMs: number; agentPolicyRolloutRateLimitRequestPerInterval: number; }; + agentPolicies?: PreconfiguredAgentPolicy[]; + packages?: PreconfiguredPackage[]; } // Calling Object.entries(PackagesGroupedByStatus) gave `status: string` diff --git a/x-pack/plugins/fleet/common/types/rest_spec/ingest_setup.ts b/x-pack/plugins/fleet/common/types/rest_spec/ingest_setup.ts index 12054aff124f7c..2180b669084982 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/ingest_setup.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/ingest_setup.ts @@ -7,4 +7,5 @@ export interface PostIngestSetupResponse { isInitialized: boolean; + preconfigurationError?: { name: string; message: string }; } diff --git a/x-pack/plugins/fleet/public/applications/fleet/app.tsx b/x-pack/plugins/fleet/public/applications/fleet/app.tsx index 2c24468b147826..5663bd4768d5cf 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/app.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/app.tsx @@ -28,6 +28,7 @@ import { sendSetup, useBreadcrumbs, useConfig, + useStartServices, } from './hooks'; import { Error, Loading } from './components'; import { IntraAppStateProvider } from './hooks/use_intra_app_state'; @@ -59,6 +60,7 @@ const Panel = styled(EuiPanel)` export const WithPermissionsAndSetup: React.FC = memo(({ children }) => { useBreadcrumbs('base'); + const { notifications } = useStartServices(); const [isPermissionsLoading, setIsPermissionsLoading] = useState(false); const [permissionsError, setPermissionsError] = useState(); @@ -81,6 +83,13 @@ export const WithPermissionsAndSetup: React.FC = memo(({ children }) => { if (setupResponse.error) { setInitializationError(setupResponse.error); } + if (setupResponse.data.preconfigurationError) { + notifications.toasts.addError(setupResponse.data.preconfigurationError, { + title: i18n.translate('xpack.fleet.setup.uiPreconfigurationErrorTitle', { + defaultMessage: 'Configuration error', + }), + }); + } } catch (err) { setInitializationError(err); } @@ -92,7 +101,7 @@ export const WithPermissionsAndSetup: React.FC = memo(({ children }) => { setPermissionsError('REQUEST_ERROR'); } })(); - }, []); + }, [notifications.toasts]); if (isPermissionsLoading || permissionsError) { return ( diff --git a/x-pack/plugins/fleet/server/constants/index.ts b/x-pack/plugins/fleet/server/constants/index.ts index 7f5586fb0f0348..27af46d0a757df 100644 --- a/x-pack/plugins/fleet/server/constants/index.ts +++ b/x-pack/plugins/fleet/server/constants/index.ts @@ -52,4 +52,5 @@ export { // Fleet Server index ENROLLMENT_API_KEYS_INDEX, AGENTS_INDEX, + PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, } from '../../common'; diff --git a/x-pack/plugins/fleet/server/index.ts b/x-pack/plugins/fleet/server/index.ts index 0178b801f4d2fc..c66dd471690eb6 100644 --- a/x-pack/plugins/fleet/server/index.ts +++ b/x-pack/plugins/fleet/server/index.ts @@ -15,6 +15,8 @@ import { AGENT_POLLING_REQUEST_TIMEOUT_MS, } from '../common'; +import { PreconfiguredPackagesSchema, PreconfiguredAgentPoliciesSchema } from './types'; + import { FleetPlugin } from './plugin'; export { default as apm } from 'elastic-apm-node'; @@ -77,6 +79,8 @@ export const config: PluginConfigDescriptor = { defaultValue: AGENT_POLICY_ROLLOUT_RATE_LIMIT_REQUEST_PER_INTERVAL, }), }), + packages: schema.maybe(PreconfiguredPackagesSchema), + agentPolicies: schema.maybe(PreconfiguredAgentPoliciesSchema), }), }; diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index 20cfae6bc1cf24..d25b1e13904db5 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -48,6 +48,7 @@ import { AGENT_SAVED_OBJECT_TYPE, AGENT_EVENT_SAVED_OBJECT_TYPE, ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, + PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, } from './constants'; import { registerSavedObjects, registerEncryptedSavedObjects } from './saved_objects'; import { @@ -133,6 +134,7 @@ const allSavedObjectTypes = [ AGENT_SAVED_OBJECT_TYPE, AGENT_EVENT_SAVED_OBJECT_TYPE, ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, + PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, ]; /** diff --git a/x-pack/plugins/fleet/server/routes/setup/handlers.test.ts b/x-pack/plugins/fleet/server/routes/setup/handlers.test.ts index 469b2409f140ac..2618f3de0d5342 100644 --- a/x-pack/plugins/fleet/server/routes/setup/handlers.test.ts +++ b/x-pack/plugins/fleet/server/routes/setup/handlers.test.ts @@ -45,7 +45,9 @@ describe('FleetSetupHandler', () => { }); it('POST /setup succeeds w/200 and body of resolved value', async () => { - mockSetupIngestManager.mockImplementation(() => Promise.resolve({ isIntialized: true })); + mockSetupIngestManager.mockImplementation(() => + Promise.resolve({ isInitialized: true, preconfigurationError: undefined }) + ); await FleetSetupHandler(context, request, response); const expectedBody: PostIngestSetupResponse = { isInitialized: true }; diff --git a/x-pack/plugins/fleet/server/routes/setup/handlers.ts b/x-pack/plugins/fleet/server/routes/setup/handlers.ts index a7fdcf78f4be99..e94c9470dd350f 100644 --- a/x-pack/plugins/fleet/server/routes/setup/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/setup/handlers.ts @@ -63,13 +63,13 @@ export const createFleetSetupHandler: RequestHandler< try { const soClient = context.core.savedObjects.client; const esClient = context.core.elasticsearch.client.asCurrentUser; - await setupIngestManager(soClient, esClient); + const body = await setupIngestManager(soClient, esClient); await setupFleet(soClient, esClient, { forceRecreate: request.body?.forceRecreate ?? false, }); return response.ok({ - body: { isInitialized: true }, + body, }); } catch (error) { return defaultIngestErrorHandler({ error, response }); @@ -81,8 +81,7 @@ export const FleetSetupHandler: RequestHandler = async (context, request, respon const esClient = context.core.elasticsearch.client.asCurrentUser; try { - const body: PostIngestSetupResponse = { isInitialized: true }; - await setupIngestManager(soClient, esClient); + const body: PostIngestSetupResponse = await setupIngestManager(soClient, esClient); return response.ok({ body, }); diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index 8554c0702f733d..58ec3972ca5179 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -19,6 +19,7 @@ import { AGENT_ACTION_SAVED_OBJECT_TYPE, ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, + PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, } from '../constants'; import { @@ -358,6 +359,19 @@ const getSavedObjectTypes = ( }, }, }, + [PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE]: { + name: PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, + hidden: false, + namespaceType: 'agnostic', + management: { + importableAndExportable: false, + }, + mappings: { + properties: { + preconfiguration_id: { type: 'keyword' }, + }, + }, + }, }); export function registerSavedObjects( diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 7f793a41ab9855..59214e287c873d 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -19,6 +19,7 @@ import { DEFAULT_AGENT_POLICY, AGENT_POLICY_SAVED_OBJECT_TYPE, AGENT_SAVED_OBJECT_TYPE, + PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, } from '../constants'; import type { PackagePolicy, @@ -150,7 +151,7 @@ class AgentPolicyService { config: PreconfiguredAgentPolicy ): Promise<{ created: boolean; - policy: AgentPolicy; + policy?: AgentPolicy; }> { const { id, ...preconfiguredAgentPolicy } = omit(config, 'package_policies'); const preconfigurationId = String(id); @@ -582,6 +583,13 @@ class AgentPolicyService { } ); } + + if (agentPolicy.preconfiguration_id) { + await soClient.create(PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, { + preconfiguration_id: String(agentPolicy.preconfiguration_id), + }); + } + await soClient.delete(SAVED_OBJECT_TYPE, id); await this.triggerAgentPolicyUpdatedEvent(soClient, esClient, 'deleted', id); return { @@ -819,5 +827,6 @@ export async function addPackageToAgentPolicy( await packagePolicyService.create(soClient, esClient, newPackagePolicy, { bumpRevision: false, + skipEnsureInstalled: true, }); } diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index 7d12aad6f32b50..1d2295a5534629 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -60,7 +60,13 @@ class PackagePolicyService { soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, packagePolicy: NewPackagePolicy, - options?: { id?: string; user?: AuthenticatedUser; bumpRevision?: boolean; force?: boolean } + options?: { + id?: string; + user?: AuthenticatedUser; + bumpRevision?: boolean; + force?: boolean; + skipEnsureInstalled?: boolean; + } ): Promise { // Check that its agent policy does not have a package policy with the same name const parentAgentPolicy = await agentPolicyService.get(soClient, packagePolicy.policy_id); @@ -90,18 +96,25 @@ class PackagePolicyService { // Make sure the associated package is installed if (packagePolicy.package?.name) { - const [, pkgInfo] = await Promise.all([ - ensureInstalledPackage({ - savedObjectsClient: soClient, - pkgName: packagePolicy.package.name, - esClient, - }), - getPackageInfo({ - savedObjectsClient: soClient, - pkgName: packagePolicy.package.name, - pkgVersion: packagePolicy.package.version, - }), - ]); + const pkgInfoPromise = getPackageInfo({ + savedObjectsClient: soClient, + pkgName: packagePolicy.package.name, + pkgVersion: packagePolicy.package.version, + }); + + let pkgInfo; + if (options?.skipEnsureInstalled) pkgInfo = await pkgInfoPromise; + else { + const [, packageInfo] = await Promise.all([ + ensureInstalledPackage({ + savedObjectsClient: soClient, + pkgName: packagePolicy.package.name, + esClient, + }), + pkgInfoPromise, + ]); + pkgInfo = packageInfo; + } // Check if it is a limited package, and if so, check that the corresponding agent policy does not // already contain a package policy for this package diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts index 8a885f9c5c821e..94865f5d3d9171 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts @@ -10,6 +10,8 @@ import { elasticsearchServiceMock, savedObjectsClientMock } from 'src/core/serve import type { PreconfiguredAgentPolicy } from '../../common/types'; import type { AgentPolicy, NewPackagePolicy, Output } from '../types'; +import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../constants'; + import { ensurePreconfiguredPackagesAndPolicies } from './preconfiguration'; const mockInstalledPackages = new Map(); @@ -27,30 +29,31 @@ const mockDefaultOutput: Output = { function getPutPreconfiguredPackagesMock() { const soClient = savedObjectsClientMock.create(); soClient.find.mockImplementation(async ({ type, search }) => { - const attributes = mockConfiguredPolicies.get(search!.replace(/"/g, '')); - if (attributes) { - return { - saved_objects: [ - { - id: `mocked-${attributes.preconfiguration_id}`, - attributes, - type: type as string, - score: 1, - references: [], - }, - ], - total: 1, - page: 1, - per_page: 1, - }; - } else { - return { - saved_objects: [], - total: 0, - page: 1, - per_page: 0, - }; + if (type === AGENT_POLICY_SAVED_OBJECT_TYPE) { + const attributes = mockConfiguredPolicies.get(search!.replace(/"/g, '')); + if (attributes) { + return { + saved_objects: [ + { + id: `mocked-${attributes.preconfiguration_id}`, + attributes, + type: type as string, + score: 1, + references: [], + }, + ], + total: 1, + page: 1, + per_page: 1, + }; + } } + return { + saved_objects: [], + total: 0, + page: 1, + per_page: 0, + }; }); soClient.create.mockImplementation(async (type, policy) => { const attributes = policy as AgentPolicy; diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.ts b/x-pack/plugins/fleet/server/services/preconfiguration.ts index 97480fcf6b2a86..3bd3169673b318 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.ts @@ -19,6 +19,9 @@ import type { PreconfiguredAgentPolicy, PreconfiguredPackage, } from '../../common'; +import { PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE } from '../constants'; + +import { escapeSearchQueryPhrase } from './saved_object'; import { pkgToPkgKey } from './epm/registry'; import { getInstallation } from './epm/packages'; @@ -69,6 +72,21 @@ export async function ensurePreconfiguredPackagesAndPolicies( // Create policies specified in Kibana config const preconfiguredPolicies = await Promise.all( policies.map(async (preconfiguredAgentPolicy) => { + // Check to see if a preconfigured policy with the same preconfigurationId was already deleted by the user + const preconfigurationId = String(preconfiguredAgentPolicy.id); + const searchParams = { + searchFields: ['preconfiguration_id'], + search: escapeSearchQueryPhrase(preconfigurationId), + }; + const deletionRecords = await soClient.find({ + type: PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, + ...searchParams, + }); + const wasDeleted = deletionRecords.total > 0; + if (wasDeleted) { + return { created: false, deleted: preconfigurationId }; + } + const { created, policy } = await agentPolicyService.ensurePreconfiguredAgentPolicy( soClient, esClient, @@ -122,22 +140,32 @@ export async function ensurePreconfiguredPackagesAndPolicies( await addPreconfiguredPolicyPackages( soClient, esClient, - policy, + policy!, installedPackagePolicies!, defaultOutput ); // Add the is_managed flag after configuring package policies to avoid errors if (shouldAddIsManagedFlag) { - agentPolicyService.update(soClient, esClient, policy.id, { is_managed: true }); + agentPolicyService.update(soClient, esClient, policy!.id, { is_managed: true }); } } } return { - policies: preconfiguredPolicies.map((p) => ({ - id: p.policy.id, - updated_at: p.policy.updated_at, - })), + policies: preconfiguredPolicies.map((p) => + p.policy + ? { + id: p.policy.id, + updated_at: p.policy.updated_at, + } + : { + id: p.deleted, + updated_at: i18n.translate('xpack.fleet.preconfiguration.policyDeleted', { + defaultMessage: 'Preconfigured policy {id} was deleted; skipping creation', + values: { id: p.deleted }, + }), + } + ), packages: preconfiguredPackages.map((pkg) => pkgToPkgKey(pkg)), }; } @@ -155,20 +183,19 @@ async function addPreconfiguredPolicyPackages( >, defaultOutput: Output ) { - return await Promise.all( - installedPackagePolicies.map(async ({ installedPackage, name, description, inputs }) => - addPackageToAgentPolicy( - soClient, - esClient, - installedPackage, - agentPolicy, - defaultOutput, - name, - description, - (policy) => overridePackageInputs(policy, inputs) - ) - ) - ); + // Add packages synchronously to avoid overwriting + for (const { installedPackage, name, description, inputs } of installedPackagePolicies) { + await addPackageToAgentPolicy( + soClient, + esClient, + installedPackage, + agentPolicy, + defaultOutput, + name, + description, + (policy) => overridePackageInputs(policy, inputs) + ); + } } async function ensureInstalledPreconfiguredPackage( diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts index b5e2326386e02d..6d98bc4263a16b 100644 --- a/x-pack/plugins/fleet/server/services/setup.ts +++ b/x-pack/plugins/fleet/server/services/setup.ts @@ -15,7 +15,9 @@ import type { PackagePolicy } from '../../common'; import { SO_SEARCH_LIMIT } from '../constants'; +import { appContextService } from './app_context'; import { agentPolicyService, addPackageToAgentPolicy } from './agent_policy'; +import { ensurePreconfiguredPackagesAndPolicies } from './preconfiguration'; import { outputService } from './output'; import { ensureInstalledDefaultPackages, @@ -34,7 +36,8 @@ const FLEET_ENROLL_USERNAME = 'fleet_enroll'; const FLEET_ENROLL_ROLE = 'fleet_enroll'; export interface SetupStatus { - isIntialized: true | undefined; + isInitialized: boolean; + preconfigurationError: { name: string; message: string } | undefined; } export async function setupIngestManager( @@ -48,17 +51,10 @@ async function createSetupSideEffects( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient ): Promise { - const [ - installedPackages, - defaultOutput, - { created: defaultAgentPolicyCreated, policy: defaultAgentPolicy }, - { created: defaultFleetServerPolicyCreated, policy: defaultFleetServerPolicy }, - ] = await Promise.all([ + const [installedPackages, defaultOutput] = await Promise.all([ // packages installed by default ensureInstalledDefaultPackages(soClient, esClient), outputService.ensureDefaultOutput(soClient), - agentPolicyService.ensureDefaultAgentPolicy(soClient, esClient), - agentPolicyService.ensureDefaultFleetServerAgentPolicy(soClient, esClient), updateFleetRoleIfExists(esClient), settingsService.getSettings(soClient).catch((e: any) => { if (e.isBoom && e.output.statusCode === 404) { @@ -86,6 +82,37 @@ async function createSetupSideEffects( esClient, }); + const { agentPolicies: policiesOrUndefined, packages: packagesOrUndefined } = + appContextService.getConfig() ?? {}; + + const policies = policiesOrUndefined ?? []; + const packages = packagesOrUndefined ?? []; + let preconfigurationError; + + try { + await ensurePreconfiguredPackagesAndPolicies( + soClient, + esClient, + policies, + packages, + defaultOutput + ); + } catch (e) { + preconfigurationError = { name: e.name, message: e.message }; + } + + // Ensure the predefined default policies AFTER loading preconfigured policies. This allows the kibana config + // to override the default agent policies. + + const [ + { created: defaultAgentPolicyCreated, policy: defaultAgentPolicy }, + { created: defaultFleetServerPolicyCreated, policy: defaultFleetServerPolicy }, + ] = await Promise.all([ + agentPolicyService.ensureDefaultAgentPolicy(soClient, esClient), + agentPolicyService.ensureDefaultFleetServerAgentPolicy(soClient, esClient), + ]); + + // If we just created the default fleet server policy add the fleet server package if (defaultFleetServerPolicyCreated) { await addPackageToAgentPolicy( soClient, @@ -96,8 +123,6 @@ async function createSetupSideEffects( ); } - // If we just created the default fleet server policy add the fleet server package - // If we just created the default policy, ensure default packages are added to it if (defaultAgentPolicyCreated) { const agentPolicyWithPackagePolicies = await agentPolicyService.get( @@ -151,7 +176,7 @@ async function createSetupSideEffects( await ensureAgentActionPolicyChangeExists(soClient); - return { isIntialized: true }; + return { isInitialized: true, preconfigurationError }; } async function updateFleetRoleIfExists(esClient: ElasticsearchClient) { From 4c00710be8b8ae419df736d352d58f4cd2a86bd7 Mon Sep 17 00:00:00 2001 From: Phillip Burch Date: Wed, 14 Apr 2021 10:46:26 -0500 Subject: [PATCH 79/90] [Metrics UI] Add Log Rate to the metrics tab (#96596) * Add Log Rate to the metrics tab * Add custom metrics to Metrics tab * Remove unused variables * Review feedback Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../tabs/metrics/chart_header.tsx | 17 +- .../tabs/metrics/chart_section.tsx | 103 +++++ .../node_details/tabs/metrics/metrics.tsx | 392 +++++++++--------- .../tabs/metrics/translations.tsx | 11 + 4 files changed, 313 insertions(+), 210 deletions(-) create mode 100644 x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_section.tsx diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_header.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_header.tsx index 03ee51477492e8..9c9e91b814fad0 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_header.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_header.tsx @@ -12,6 +12,7 @@ import { EuiFlexGroup } from '@elastic/eui'; import { EuiIcon } from '@elastic/eui'; import { colorTransformer } from '../../../../../../../../common/color_palette'; import { MetricsExplorerOptionsMetric } from '../../../../../metrics_explorer/hooks/use_metrics_explorer_options'; +import { euiStyled } from '../../../../../../../../../../../src/plugins/kibana_react/common'; interface Props { title: string; @@ -21,11 +22,11 @@ interface Props { export const ChartHeader = ({ title, metrics }: Props) => { return ( - + -

{title}

+

{title}

-
+ {metrics.map((chartMetric) => ( @@ -50,3 +51,13 @@ export const ChartHeader = ({ title, metrics }: Props) => { ); }; + +const HeaderItem = euiStyled(EuiFlexItem).attrs({ grow: 1 })` + overflow: hidden; +`; + +const H4 = euiStyled('h4')` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_section.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_section.tsx new file mode 100644 index 00000000000000..c8f924042b1951 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_section.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + Axis, + Settings, + Position, + Chart, + PointerUpdateListener, + TickFormatter, + TooltipValue, + ChartSizeArray, +} from '@elastic/charts'; +import React from 'react'; +import moment from 'moment'; +import { MetricsExplorerSeries } from '../../../../../../../../common/http_api'; +import { MetricExplorerSeriesChart } from '../../../../../metrics_explorer/components/series_chart'; +import { + MetricsExplorerChartType, + MetricsExplorerOptionsMetric, +} from '../../../../../metrics_explorer/hooks/use_metrics_explorer_options'; +import { ChartHeader } from './chart_header'; +import { getTimelineChartTheme } from '../../../../../metrics_explorer/components/helpers/get_chart_theme'; +import { useUiSetting } from '../../../../../../../../../../../src/plugins/kibana_react/public'; + +const CHART_SIZE: ChartSizeArray = ['100%', 160]; + +interface Props { + title: string; + style: MetricsExplorerChartType; + chartRef: React.Ref; + series: ChartSectionSeries[]; + tickFormatterForTime: TickFormatter; + tickFormatter: TickFormatter; + onPointerUpdate: PointerUpdateListener; + domain: { max: number; min: number }; + stack?: boolean; +} + +export interface ChartSectionSeries { + metric: MetricsExplorerOptionsMetric; + series: MetricsExplorerSeries; +} + +export const ChartSection = ({ + title, + style, + chartRef, + series, + tickFormatterForTime, + tickFormatter, + onPointerUpdate, + domain, + stack = false, +}: Props) => { + const isDarkMode = useUiSetting('theme:darkMode'); + const metrics = series.map((chartSeries) => chartSeries.metric); + const tooltipProps = { + headerFormatter: (tooltipValue: TooltipValue) => + moment(tooltipValue.value).format('Y-MM-DD HH:mm:ss.SSS'), + }; + + return ( + <> + + + {series.map((chartSeries, index) => ( + + ))} + + + + + + ); +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx index 5ab8eb380a6576..b554cb8024211c 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx @@ -8,17 +8,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { first, last } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { - Axis, - Chart, - ChartSizeArray, - niceTimeFormatter, - Position, - Settings, - TooltipValue, - PointerEvent, -} from '@elastic/charts'; -import moment from 'moment'; +import { Chart, niceTimeFormatter, PointerEvent } from '@elastic/charts'; import { EuiLoadingChart, EuiSpacer, EuiFlexGrid, EuiFlexItem } from '@elastic/eui'; import { TabContent, TabProps } from '../shared'; import { useSnapshot } from '../../../../hooks/use_snaphot'; @@ -36,12 +26,10 @@ import { MetricsExplorerAggregation, MetricsExplorerSeries, } from '../../../../../../../../common/http_api'; -import { MetricExplorerSeriesChart } from '../../../../../metrics_explorer/components/series_chart'; import { createInventoryMetricFormatter } from '../../../../lib/create_inventory_metric_formatter'; import { calculateDomain } from '../../../../../metrics_explorer/components/helpers/calculate_domain'; -import { getTimelineChartTheme } from '../../../../../metrics_explorer/components/helpers/get_chart_theme'; -import { useUiSetting } from '../../../../../../../../../../../src/plugins/kibana_react/public'; -import { ChartHeader } from './chart_header'; +import { euiStyled } from '../../../../../../../../../../../src/plugins/kibana_react/common'; +import { ChartSection } from './chart_section'; import { SYSTEM_METRIC_NAME, USER_METRIC_NAME, @@ -53,26 +41,36 @@ import { LOAD_CHART_TITLE, MEMORY_CHART_TITLE, NETWORK_CHART_TITLE, + LOG_RATE_METRIC_NAME, + LOG_RATE_CHART_TITLE, } from './translations'; import { TimeDropdown } from './time_dropdown'; +import { getCustomMetricLabel } from '../../../../../../../../common/formatters/get_custom_metric_label'; +import { createFormatterForMetric } from '../../../../../metrics_explorer/components/helpers/create_formatter_for_metric'; const ONE_HOUR = 60 * 60 * 1000; -const CHART_SIZE: ChartSizeArray = ['100%', 160]; const TabComponent = (props: TabProps) => { const cpuChartRef = useRef(null); const networkChartRef = useRef(null); const memoryChartRef = useRef(null); const loadChartRef = useRef(null); + const logRateChartRef = useRef(null); + const customMetricRefs = useRef>({}); const [time, setTime] = useState(ONE_HOUR); - const chartRefs = useMemo(() => [cpuChartRef, networkChartRef, memoryChartRef, loadChartRef], [ + const chartRefs = useMemo(() => { + const refs = [cpuChartRef, networkChartRef, memoryChartRef, loadChartRef, logRateChartRef]; + return [...refs, customMetricRefs]; + }, [ cpuChartRef, networkChartRef, memoryChartRef, loadChartRef, + logRateChartRef, + customMetricRefs, ]); const { sourceId, createDerivedIndexPattern } = useSourceContext(); - const { nodeType, accountId, region } = useWaffleOptionsContext(); + const { nodeType, accountId, region, customMetrics } = useWaffleOptionsContext(); const { currentTime, options, node } = props; const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [ createDerivedIndexPattern, @@ -102,20 +100,29 @@ const TabComponent = (props: TabProps) => { [setTime] ); + const timeRange = { + interval: '1m', + to: currentTime, + from: currentTime - time, + ignoreLookback: true, + }; + + const defaultMetrics: Array<{ type: SnapshotMetricType }> = [ + { type: 'rx' }, + { type: 'tx' }, + buildCustomMetric('system.cpu.user.pct', 'user'), + buildCustomMetric('system.cpu.system.pct', 'system'), + buildCustomMetric('system.load.1', 'load1m'), + buildCustomMetric('system.load.5', 'load5m'), + buildCustomMetric('system.load.15', 'load15m'), + buildCustomMetric('system.memory.actual.used.bytes', 'usedMemory'), + buildCustomMetric('system.memory.actual.free', 'freeMemory'), + buildCustomMetric('system.cpu.cores', 'cores', 'max'), + ]; + const { nodes, reload } = useSnapshot( filter, - [ - { type: 'rx' }, - { type: 'tx' }, - buildCustomMetric('system.cpu.user.pct', 'user'), - buildCustomMetric('system.cpu.system.pct', 'system'), - buildCustomMetric('system.load.1', 'load1m'), - buildCustomMetric('system.load.5', 'load5m'), - buildCustomMetric('system.load.15', 'load15m'), - buildCustomMetric('system.memory.actual.used.bytes', 'usedMemory'), - buildCustomMetric('system.memory.actual.free', 'freeMemory'), - buildCustomMetric('system.cpu.cores', 'cores', 'max'), - ], + [...defaultMetrics, ...customMetrics], [], nodeType, sourceId, @@ -123,12 +130,20 @@ const TabComponent = (props: TabProps) => { accountId, region, false, - { - interval: '1m', - to: currentTime, - from: currentTime - time, - ignoreLookback: true, - } + timeRange + ); + + const { nodes: logRateNodes, reload: reloadLogRate } = useSnapshot( + filter, + [{ type: 'logRate' }], + [], + nodeType, + sourceId, + currentTime, + accountId, + region, + false, + timeRange ); const getDomain = useCallback( @@ -163,6 +178,7 @@ const TabComponent = (props: TabProps) => { [] ); const loadFormatter = useMemo(() => createInventoryMetricFormatter({ type: 'load' }), []); + const logRateFormatter = useMemo(() => createInventoryMetricFormatter({ type: 'logRate' }), []); const mergeTimeseries = useCallback((...series: MetricsExplorerSeries[]) => { const base = series[0]; @@ -196,19 +212,22 @@ const TabComponent = (props: TabProps) => { (event: PointerEvent) => { chartRefs.forEach((ref) => { if (ref.current) { - ref.current.dispatchExternalPointerEvent(event); + if (ref.current instanceof Chart) { + ref.current.dispatchExternalPointerEvent(event); + } else { + const charts = Object.values(ref.current); + charts.forEach((c) => { + if (c) { + c.dispatchExternalPointerEvent(event); + } + }); + } } }); }, [chartRefs] ); - const isDarkMode = useUiSetting('theme:darkMode'); - const tooltipProps = { - headerFormatter: (tooltipValue: TooltipValue) => - moment(tooltipValue.value).format('Y-MM-DD HH:mm:ss.SSS'), - }; - const getTimeseries = useCallback( (metricName: string) => { if (!nodes || !nodes.length) { @@ -219,6 +238,16 @@ const TabComponent = (props: TabProps) => { [nodes] ); + const getLogRateTimeseries = useCallback(() => { + if (!logRateNodes) { + return null; + } + if (logRateNodes.length === 0) { + return { rows: [], columns: [], id: '0' }; + } + return logRateNodes[0].metrics.find((m) => m.name === 'logRate')!.timeseries!; + }, [logRateNodes]); + const systemMetricsTs = useMemo(() => getTimeseries('system'), [getTimeseries]); const userMetricsTs = useMemo(() => getTimeseries('user'), [getTimeseries]); const rxMetricsTs = useMemo(() => getTimeseries('rx'), [getTimeseries]); @@ -229,10 +258,12 @@ const TabComponent = (props: TabProps) => { const usedMemoryMetricsTs = useMemo(() => getTimeseries('usedMemory'), [getTimeseries]); const freeMemoryMetricsTs = useMemo(() => getTimeseries('freeMemory'), [getTimeseries]); const coresMetricsTs = useMemo(() => getTimeseries('cores'), [getTimeseries]); + const logRateMetricsTs = useMemo(() => getLogRateTimeseries(), [getLogRateTimeseries]); useEffect(() => { reload(); - }, [time, reload]); + reloadLogRate(); + }, [time, reload, reloadLogRate]); if ( !systemMetricsTs || @@ -243,12 +274,14 @@ const TabComponent = (props: TabProps) => { !load5mMetricsTs || !load15mMetricsTs || !usedMemoryMetricsTs || - !freeMemoryMetricsTs + !freeMemoryMetricsTs || + !logRateMetricsTs ) { return ; } const cpuChartMetrics = buildChartMetricLabels([SYSTEM_METRIC_NAME, USER_METRIC_NAME], 'avg'); + const logRateChartMetrics = buildChartMetricLabels([LOG_RATE_METRIC_NAME], 'rate'); const networkChartMetrics = buildChartMetricLabels( [INBOUND_METRIC_NAME, OUTBOUND_METRIC_NAME], 'rate' @@ -277,6 +310,7 @@ const TabComponent = (props: TabProps) => { return r; }); const cpuTimeseries = mergeTimeseries(systemMetricsTs, userMetricsTs); + const logRateTimeseries = mergeTimeseries(logRateMetricsTs); const networkTimeseries = mergeTimeseries(rxMetricsTs, txMetricsTs); const loadTimeseries = mergeTimeseries(load1mMetricsTs, load5mMetricsTs, load15mMetricsTs); const memoryTimeseries = mergeTimeseries(usedMemoryMetricsTs, freeMemoryMetricsTs); @@ -290,173 +324,117 @@ const TabComponent = (props: TabProps) => { - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + {customMetrics.map((c) => { + const metricTS = getTimeseries(c.id); + const chartMetrics = buildChartMetricLabels([c.field], c.aggregation); + if (!metricTS) return null; + return ( + + { + customMetricRefs.current[c.id] = r; + }} + series={[{ metric: chartMetrics[0], series: metricTS }]} + tickFormatterForTime={formatter} + tickFormatter={createFormatterForMetric(c)} + onPointerUpdate={pointerUpdate} + domain={getDomain(mergeTimeseries(metricTS), chartMetrics)} + stack={true} + /> + + ); + })} ); }; +const ChartGridItem = euiStyled(EuiFlexItem)` + overflow: hidden +`; + const LoadingPlaceholder = () => { return (
Date: Wed, 14 Apr 2021 12:14:57 -0400 Subject: [PATCH 80/90] [App Search] Remaining Result Settings work (#96974) --- .../credentials_list.test.tsx | 2 +- .../result_settings/result_settings.test.tsx | 56 ++++- .../result_settings/result_settings.tsx | 100 +++++--- .../result_settings_logic.test.ts | 218 ++++++++++-------- .../result_settings/result_settings_logic.ts | 8 +- .../components/result_settings/utils.test.ts | 30 --- .../components/result_settings/utils.ts | 9 +- 7 files changed, 249 insertions(+), 174 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_list/credentials_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_list/credentials_list.test.tsx index 09340d37fcf7b2..274bda56a2fc12 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_list/credentials_list.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_list/credentials_list.test.tsx @@ -87,7 +87,7 @@ describe('CredentialsList', () => { }); describe('empty state', () => { - it('renders an EuiEmptyState when no credentials are available', () => { + it('renders an EuiEmptyPrompt when no credentials are available', () => { setMockValues({ ...values, apiTokens: [], diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx index a1e1fd920b1398..e5a901f8d07790 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx @@ -13,15 +13,20 @@ import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; -import { EuiPageHeader } from '@elastic/eui'; +import { EuiPageHeader, EuiEmptyPrompt } from '@elastic/eui'; import { ResultSettings } from './result_settings'; import { ResultSettingsTable } from './result_settings_table'; import { SampleResponse } from './sample_response'; -describe('RelevanceTuning', () => { +describe('ResultSettings', () => { const values = { + schema: { + foo: 'text', + }, dataLoading: false, + stagedUpdates: true, + resultFieldsAtDefaultSettings: false, }; const actions = { @@ -32,9 +37,9 @@ describe('RelevanceTuning', () => { }; beforeEach(() => { + jest.clearAllMocks(); setMockValues(values); setMockActions(actions); - jest.clearAllMocks(); }); const subject = () => shallow(); @@ -69,6 +74,16 @@ describe('RelevanceTuning', () => { expect(actions.saveResultSettings).toHaveBeenCalled(); }); + it('renders the "save" button as disabled if the user has made no changes since the page loaded', () => { + setMockValues({ + ...values, + stagedUpdates: false, + }); + const buttons = findButtons(subject()); + const saveButton = shallow(buttons[0]); + expect(saveButton.prop('disabled')).toBe(true); + }); + it('renders a "restore defaults" button that will reset all values to their defaults', () => { const buttons = findButtons(subject()); expect(buttons.length).toBe(3); @@ -77,6 +92,16 @@ describe('RelevanceTuning', () => { expect(actions.confirmResetAllFields).toHaveBeenCalled(); }); + it('renders the "restore defaults" button as disabled if the values are already at their defaults', () => { + setMockValues({ + ...values, + resultFieldsAtDefaultSettings: true, + }); + const buttons = findButtons(subject()); + const resetButton = shallow(buttons[1]); + expect(resetButton.prop('disabled')).toBe(true); + }); + it('renders a "clear" button that will remove all selected options', () => { const buttons = findButtons(subject()); expect(buttons.length).toBe(3); @@ -84,4 +109,29 @@ describe('RelevanceTuning', () => { clearButton.simulate('click'); expect(actions.clearAllFields).toHaveBeenCalled(); }); + + describe('when there is no schema yet', () => { + let wrapper: ShallowWrapper; + beforeAll(() => { + setMockValues({ + ...values, + schema: {}, + }); + wrapper = subject(); + }); + + it('will not render action buttons', () => { + const buttons = findButtons(wrapper); + expect(buttons.length).toBe(0); + }); + + it('will not render the main page content', () => { + expect(wrapper.find(ResultSettingsTable).exists()).toBe(false); + expect(wrapper.find(SampleResponse).exists()).toBe(false); + }); + + it('will render an "empty" message', () => { + expect(wrapper.find(EuiEmptyPrompt).exists()).toBe(true); + }); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx index 70dbee7425ae81..285d8fef357703 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx @@ -9,7 +9,15 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; -import { EuiPageHeader, EuiFlexGroup, EuiFlexItem, EuiButton, EuiButtonEmpty } from '@elastic/eui'; +import { + EuiPageHeader, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiButtonEmpty, + EuiEmptyPrompt, + EuiPanel, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -32,7 +40,9 @@ const CLEAR_BUTTON_LABEL = i18n.translate( ); export const ResultSettings: React.FC = () => { - const { dataLoading } = useValues(ResultSettingsLogic); + const { dataLoading, schema, stagedUpdates, resultFieldsAtDefaultSettings } = useValues( + ResultSettingsLogic + ); const { initializeResultSettingsData, saveResultSettings, @@ -45,6 +55,7 @@ export const ResultSettings: React.FC = () => { }, []); if (dataLoading) return ; + const hasSchema = Object.keys(schema).length > 0; return ( <> @@ -55,36 +66,65 @@ export const ResultSettings: React.FC = () => { 'xpack.enterpriseSearch.appSearch.engine.resultSettings.pageDescription', { defaultMessage: 'Enrich search results and select which fields will appear.' } )} - rightSideItems={[ - - {SAVE_BUTTON_LABEL} - , - - {RESTORE_DEFAULTS_BUTTON_LABEL} - , - - {CLEAR_BUTTON_LABEL} - , - ]} + rightSideItems={ + hasSchema + ? [ + + {SAVE_BUTTON_LABEL} + , + + {RESTORE_DEFAULTS_BUTTON_LABEL} + , + + {CLEAR_BUTTON_LABEL} + , + ] + : [] + } /> - - - - - - - - + {hasSchema ? ( + + + + + + + + + ) : ( + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.resultSettings.noSchemaTitle', + { defaultMessage: 'Engine does not have a schema' } + )} +
+ } + body={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.resultSettings.noSchemaDescription', + { + defaultMessage: + 'You need one! A schema is created for you after you index some documents.', + } + )} + /> +
+ )} ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.test.ts index 8d9c33e3c9e680..437949982cb5aa 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.test.ts @@ -19,6 +19,18 @@ import { ServerFieldResultSettingObject } from './types'; import { ResultSettingsLogic } from '.'; +// toHaveBeenCalledWith uses toEqual which is a more lenient check. We have a couple of +// methods that need a stricter check, using `toStrictEqual`. +const expectToHaveBeenCalledWithStrict = ( + mock: jest.Mock, + expectedParam1: string, + expectedParam2: object +) => { + const [param1, param2] = mock.mock.calls[0]; + expect(param1).toEqual(expectedParam1); + expect(param2).toStrictEqual(expectedParam2); +}; + describe('ResultSettingsLogic', () => { const { mount } = new LogicMounter(ResultSettingsLogic); @@ -35,7 +47,6 @@ describe('ResultSettingsLogic', () => { serverResultFields: {}, reducedServerResultFields: {}, resultFieldsAtDefaultSettings: true, - resultFieldsEmpty: true, stagedUpdates: false, nonTextResultFields: {}, textResultFields: {}, @@ -322,30 +333,6 @@ describe('ResultSettingsLogic', () => { }); }); - describe('resultFieldsEmpty', () => { - it('should return true if all fields are empty', () => { - mount({ - resultFields: { - foo: {}, - bar: {}, - }, - }); - - expect(ResultSettingsLogic.values.resultFieldsEmpty).toEqual(true); - }); - - it('should return false otherwise', () => { - mount({ - resultFields: { - foo: {}, - bar: { raw: true, snippet: true, snippetFallback: false }, - }, - }); - - expect(ResultSettingsLogic.values.resultFieldsEmpty).toEqual(false); - }); - }); - describe('stagedUpdates', () => { it('should return true if changes have been made since the last save', () => { mount({ @@ -535,17 +522,20 @@ describe('ResultSettingsLogic', () => { mount({ resultFields: { foo: { raw: true, rawSize: 5, snippet: false }, - bar: { raw: true, rawSize: 5, snippet: false }, }, }); jest.spyOn(ResultSettingsLogic.actions, 'updateField'); ResultSettingsLogic.actions.clearRawSizeForField('foo'); - expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('foo', { - raw: true, - snippet: false, - }); + expectToHaveBeenCalledWithStrict( + ResultSettingsLogic.actions.updateField as jest.Mock, + 'foo', + { + raw: true, + snippet: false, + } + ); }); }); @@ -554,17 +544,20 @@ describe('ResultSettingsLogic', () => { mount({ resultFields: { foo: { raw: false, snippet: true, snippetSize: 5 }, - bar: { raw: true, rawSize: 5, snippet: false }, }, }); jest.spyOn(ResultSettingsLogic.actions, 'updateField'); ResultSettingsLogic.actions.clearSnippetSizeForField('foo'); - expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('foo', { - raw: false, - snippet: true, - }); + expectToHaveBeenCalledWithStrict( + ResultSettingsLogic.actions.updateField as jest.Mock, + 'foo', + { + raw: false, + snippet: true, + } + ); }); }); @@ -572,7 +565,6 @@ describe('ResultSettingsLogic', () => { it('should toggle the raw value on for a field', () => { mount({ resultFields: { - foo: { raw: false, snippet: true, snippetSize: 5 }, bar: { raw: false, snippet: false }, }, }); @@ -580,16 +572,19 @@ describe('ResultSettingsLogic', () => { ResultSettingsLogic.actions.toggleRawForField('bar'); - expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('bar', { - raw: true, - snippet: false, - }); + expectToHaveBeenCalledWithStrict( + ResultSettingsLogic.actions.updateField as jest.Mock, + 'bar', + { + raw: true, + snippet: false, + } + ); }); it('should maintain rawSize if it was set prior', () => { mount({ resultFields: { - foo: { raw: false, snippet: true, snippetSize: 5 }, bar: { raw: false, rawSize: 10, snippet: false }, }, }); @@ -597,17 +592,20 @@ describe('ResultSettingsLogic', () => { ResultSettingsLogic.actions.toggleRawForField('bar'); - expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('bar', { - raw: true, - rawSize: 10, - snippet: false, - }); + expectToHaveBeenCalledWithStrict( + ResultSettingsLogic.actions.updateField as jest.Mock, + 'bar', + { + raw: true, + rawSize: 10, + snippet: false, + } + ); }); it('should remove rawSize value when toggling off', () => { mount({ resultFields: { - foo: { raw: false, snippet: true, snippetSize: 5 }, bar: { raw: true, rawSize: 5, snippet: false }, }, }); @@ -615,16 +613,19 @@ describe('ResultSettingsLogic', () => { ResultSettingsLogic.actions.toggleRawForField('bar'); - expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('bar', { - raw: false, - snippet: false, - }); + expectToHaveBeenCalledWithStrict( + ResultSettingsLogic.actions.updateField as jest.Mock, + 'bar', + { + raw: false, + snippet: false, + } + ); }); it('should still work if the object is empty', () => { mount({ resultFields: { - foo: { raw: false, snippet: true, snippetSize: 5 }, bar: {}, }, }); @@ -632,9 +633,13 @@ describe('ResultSettingsLogic', () => { ResultSettingsLogic.actions.toggleRawForField('bar'); - expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('bar', { - raw: true, - }); + expectToHaveBeenCalledWithStrict( + ResultSettingsLogic.actions.updateField as jest.Mock, + 'bar', + { + raw: true, + } + ); }); }); @@ -642,7 +647,6 @@ describe('ResultSettingsLogic', () => { it('should toggle the raw value on for a field, always setting the snippet size to 100', () => { mount({ resultFields: { - foo: { raw: false, snippet: true, snippetSize: 5 }, bar: { raw: false, snippet: false }, }, }); @@ -650,17 +654,20 @@ describe('ResultSettingsLogic', () => { ResultSettingsLogic.actions.toggleSnippetForField('bar'); - expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('bar', { - raw: false, - snippet: true, - snippetSize: 100, - }); + expectToHaveBeenCalledWithStrict( + ResultSettingsLogic.actions.updateField as jest.Mock, + 'bar', + { + raw: false, + snippet: true, + snippetSize: 100, + } + ); }); it('should remove rawSize value when toggling off', () => { mount({ resultFields: { - foo: { raw: false, snippet: true, snippetSize: 5 }, bar: { raw: false, snippet: true, snippetSize: 5 }, }, }); @@ -668,16 +675,19 @@ describe('ResultSettingsLogic', () => { ResultSettingsLogic.actions.toggleSnippetForField('bar'); - expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('bar', { - raw: false, - snippet: false, - }); + expectToHaveBeenCalledWithStrict( + ResultSettingsLogic.actions.updateField as jest.Mock, + 'bar', + { + raw: false, + snippet: false, + } + ); }); it('should still work if the object is empty', () => { mount({ resultFields: { - foo: { raw: false, snippet: true, snippetSize: 5 }, bar: {}, }, }); @@ -685,10 +695,14 @@ describe('ResultSettingsLogic', () => { ResultSettingsLogic.actions.toggleSnippetForField('bar'); - expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('bar', { - snippet: true, - snippetSize: 100, - }); + expectToHaveBeenCalledWithStrict( + ResultSettingsLogic.actions.updateField as jest.Mock, + 'bar', + { + snippet: true, + snippetSize: 100, + } + ); }); }); @@ -697,19 +711,22 @@ describe('ResultSettingsLogic', () => { mount({ resultFields: { foo: { raw: false, snippet: true, snippetSize: 5, snippetFallback: true }, - bar: { raw: false, snippet: false }, }, }); jest.spyOn(ResultSettingsLogic.actions, 'updateField'); ResultSettingsLogic.actions.toggleSnippetFallbackForField('foo'); - expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('foo', { - raw: false, - snippet: true, - snippetSize: 5, - snippetFallback: false, - }); + expectToHaveBeenCalledWithStrict( + ResultSettingsLogic.actions.updateField as jest.Mock, + 'foo', + { + raw: false, + snippet: true, + snippetSize: 5, + snippetFallback: false, + } + ); }); }); @@ -717,7 +734,6 @@ describe('ResultSettingsLogic', () => { it('should update the rawSize value for a field', () => { mount({ resultFields: { - foo: { raw: false, snippet: true, snippetSize: 5, snippetFallback: true }, bar: { raw: true, rawSize: 5, snippet: false }, }, }); @@ -725,11 +741,15 @@ describe('ResultSettingsLogic', () => { ResultSettingsLogic.actions.updateRawSizeForField('bar', 7); - expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('bar', { - raw: true, - rawSize: 7, - snippet: false, - }); + expectToHaveBeenCalledWithStrict( + ResultSettingsLogic.actions.updateField as jest.Mock, + 'bar', + { + raw: true, + rawSize: 7, + snippet: false, + } + ); }); }); @@ -738,19 +758,22 @@ describe('ResultSettingsLogic', () => { mount({ resultFields: { foo: { raw: false, snippet: true, snippetSize: 5, snippetFallback: true }, - bar: { raw: true, rawSize: 5, snippet: false }, }, }); jest.spyOn(ResultSettingsLogic.actions, 'updateField'); ResultSettingsLogic.actions.updateSnippetSizeForField('foo', 7); - expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('foo', { - raw: false, - snippet: true, - snippetSize: 7, - snippetFallback: true, - }); + expectToHaveBeenCalledWithStrict( + ResultSettingsLogic.actions.updateField as jest.Mock, + 'foo', + { + raw: false, + snippet: true, + snippetSize: 7, + snippetFallback: true, + } + ); }); }); @@ -759,17 +782,20 @@ describe('ResultSettingsLogic', () => { mount({ resultFields: { foo: { raw: false, snippet: true, snippetSize: 5 }, - bar: { raw: true, rawSize: 5, snippet: false }, }, }); jest.spyOn(ResultSettingsLogic.actions, 'updateField'); ResultSettingsLogic.actions.clearSnippetSizeForField('foo'); - expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('foo', { - raw: false, - snippet: true, - }); + expectToHaveBeenCalledWithStrict( + ResultSettingsLogic.actions.updateField as jest.Mock, + 'foo', + { + raw: false, + snippet: true, + } + ); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts index f518fc945bfbf0..af78543cda2b23 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts @@ -24,7 +24,6 @@ import { import { areFieldsAtDefaultSettings, - areFieldsEmpty, clearAllFields, convertServerResultFieldsToResultFields, convertToServerFieldResultSetting, @@ -198,10 +197,6 @@ export const ResultSettingsLogic = kea [selectors.resultFields], (resultFields) => areFieldsAtDefaultSettings(resultFields), ], - resultFieldsEmpty: [ - () => [selectors.resultFields], - (resultFields) => areFieldsEmpty(resultFields), - ], stagedUpdates: [ () => [selectors.lastSavedResultFields, selectors.resultFields], (lastSavedResultFields, resultFields) => !isEqual(lastSavedResultFields, resultFields), @@ -256,10 +251,11 @@ export const ResultSettingsLogic = kea { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/utils.test.ts index 5797e5c633bc7d..6fee0a25003575 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/utils.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/utils.test.ts @@ -9,7 +9,6 @@ import { SchemaTypes } from '../../../shared/types'; import { areFieldsAtDefaultSettings, - areFieldsEmpty, convertServerResultFieldsToResultFields, convertToServerFieldResultSetting, clearAllFields, @@ -145,35 +144,6 @@ describe('splitResultFields', () => { }); }); -describe('areFieldsEmpty', () => { - it('should return true if all fields are empty objects', () => { - expect( - areFieldsEmpty({ - foo: {}, - bar: {}, - }) - ).toBe(true); - }); - it('should return false otherwise', () => { - expect( - areFieldsEmpty({ - foo: { - raw: true, - rawSize: 5, - snippet: false, - snippetFallback: false, - }, - bar: { - raw: true, - rawSize: 5, - snippet: false, - snippetFallback: false, - }, - }) - ).toBe(false); - }); -}); - describe('areFieldsAtDefaultSettings', () => { it('will return true if all settings for all fields are at their defaults', () => { expect( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/utils.ts index bde67c268ac168..ff88aaac193d78 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/utils.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/utils.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { isEqual, isEmpty } from 'lodash'; +import { isEqual } from 'lodash'; import { Schema } from '../../../shared/types'; @@ -112,13 +112,6 @@ export const splitResultFields = (resultFields: FieldResultSettingObject, schema return { textResultFields, nonTextResultFields }; }; -export const areFieldsEmpty = (fields: FieldResultSettingObject) => { - const anyNonEmptyField = Object.values(fields).find((resultSettings) => { - return !isEmpty(resultSettings); - }); - return !anyNonEmptyField; -}; - export const areFieldsAtDefaultSettings = (fields: FieldResultSettingObject) => { const anyNonDefaultSettingsValue = Object.values(fields).find((resultSettings) => { return !isEqual(resultSettings, DEFAULT_FIELD_SETTINGS); From 1615d5f62b843d969131dc61ea4e5baeddc95eed Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Wed, 14 Apr 2021 09:20:59 -0700 Subject: [PATCH 81/90] Reporting: Refactor functional tests with security roles checks (#96856) * Reporting: Refactor functional tests with security roles checks * consolidate initEcommerce calls Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../workpad_header/share_menu/share_menu.ts | 1 + x-pack/scripts/functional_tests.js | 2 + .../ftr_provider_context.d.ts | 3 +- .../reporting_and_security.config.ts | 25 +-- ...diate.snap => download_csv_dashboard.snap} | 0 .../reporting_and_security/constants.ts | 18 -- ...immediate.ts => download_csv_dashboard.ts} | 29 +-- ...job_params.ts => generate_csv_discover.ts} | 2 +- .../reporting_and_security/index.ts | 15 +- .../security_roles_privileges.ts | 192 ++++++++++++++++++ .../reporting_and_security/usage.ts | 22 +- .../reporting_without_security.config.ts | 25 +-- .../reporting_without_security/index.ts | 3 +- .../reporting_without_security/job_apis.ts | 4 +- .../{ => services}/fixtures.ts | 0 .../{ => services}/generation_urls.ts | 0 .../services/index.ts | 26 +++ .../services/scenarios.ts | 154 ++++++++++++++ .../{services.ts => services/usage.ts} | 83 +------- .../ftr_provider_context.d.ts | 12 ++ .../reporting_and_security.config.ts | 37 ++++ .../reporting_and_security/index.ts | 57 ++++++ .../reporting_and_security/management.ts | 37 ++++ .../security_roles_privileges.ts | 109 ++++++++++ .../reporting_without_security.config.ts | 34 ++++ .../reporting_without_security/index.ts | 16 ++ .../reporting_without_security/management.ts | 2 +- .../reporting_functional/services/index.ts | 19 ++ .../services/scenarios.ts | 165 +++++++++++++++ 29 files changed, 916 insertions(+), 176 deletions(-) rename x-pack/test/reporting_api_integration/reporting_and_security/__snapshots__/{csv_searchsource_immediate.snap => download_csv_dashboard.snap} (100%) delete mode 100644 x-pack/test/reporting_api_integration/reporting_and_security/constants.ts rename x-pack/test/reporting_api_integration/reporting_and_security/{csv_searchsource_immediate.ts => download_csv_dashboard.ts} (94%) rename x-pack/test/reporting_api_integration/reporting_and_security/{csv_job_params.ts => generate_csv_discover.ts} (97%) create mode 100644 x-pack/test/reporting_api_integration/reporting_and_security/security_roles_privileges.ts rename x-pack/test/reporting_api_integration/{ => services}/fixtures.ts (100%) rename x-pack/test/reporting_api_integration/{ => services}/generation_urls.ts (100%) create mode 100644 x-pack/test/reporting_api_integration/services/index.ts create mode 100644 x-pack/test/reporting_api_integration/services/scenarios.ts rename x-pack/test/reporting_api_integration/{services.ts => services/usage.ts} (53%) create mode 100644 x-pack/test/reporting_functional/ftr_provider_context.d.ts create mode 100644 x-pack/test/reporting_functional/reporting_and_security.config.ts create mode 100644 x-pack/test/reporting_functional/reporting_and_security/index.ts create mode 100644 x-pack/test/reporting_functional/reporting_and_security/management.ts create mode 100644 x-pack/test/reporting_functional/reporting_and_security/security_roles_privileges.ts create mode 100644 x-pack/test/reporting_functional/reporting_without_security.config.ts create mode 100644 x-pack/test/reporting_functional/reporting_without_security/index.ts rename x-pack/test/{reporting_api_integration => reporting_functional}/reporting_without_security/management.ts (96%) create mode 100644 x-pack/test/reporting_functional/services/index.ts create mode 100644 x-pack/test/reporting_functional/services/scenarios.ts diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts index 942ae428e36910..a0448504db54be 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts @@ -91,6 +91,7 @@ export const ShareMenu = compose( .catch((err: Error) => { services.notify.error(err, { title: strings.getExportPDFErrorTitle(workpad.name), + 'data-test-subj': 'queueReportError', }); }); case 'json': diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 1f6fe310bfa7cc..450cbc224eb487 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -12,6 +12,8 @@ const alwaysImportedTests = [ require.resolve('../test/plugin_functional/config.ts'), require.resolve('../test/functional_with_es_ssl/config.ts'), require.resolve('../test/functional/config_security_basic.ts'), + require.resolve('../test/reporting_functional/reporting_and_security.config.ts'), + require.resolve('../test/reporting_functional/reporting_without_security.config.ts'), require.resolve('../test/security_functional/login_selector.config.ts'), require.resolve('../test/security_functional/oidc.config.ts'), require.resolve('../test/security_functional/saml.config.ts'), diff --git a/x-pack/test/reporting_api_integration/ftr_provider_context.d.ts b/x-pack/test/reporting_api_integration/ftr_provider_context.d.ts index 809f464289ff26..671866cad6ff5d 100644 --- a/x-pack/test/reporting_api_integration/ftr_provider_context.d.ts +++ b/x-pack/test/reporting_api_integration/ftr_provider_context.d.ts @@ -6,7 +6,6 @@ */ import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; -import { pageObjects } from '../functional/page_objects'; // Reporting APIs depend on UI functionality import { services } from './services'; -export type FtrProviderContext = GenericFtrProviderContext; +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/reporting_api_integration/reporting_and_security.config.ts b/x-pack/test/reporting_api_integration/reporting_and_security.config.ts index ddd6fe046dd31b..623799c84d8600 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security.config.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security.config.ts @@ -5,16 +5,14 @@ * 2.0. */ -// @ts-expect-error https://github.com/elastic/kibana/issues/95679 -import { esTestConfig, kbnTestConfig, kibanaServerTestUser } from '@kbn/test'; import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; -import { format as formatUrl } from 'url'; +import { resolve } from 'path'; import { ReportingAPIProvider } from './services'; export default async function ({ readConfigFile }: FtrConfigProviderContext) { const apiConfig = await readConfigFile(require.resolve('../api_integration/config')); - const functionalConfig = await readConfigFile(require.resolve('../functional/config')); // Reporting API tests need a fully working UI + // config for testing network policy const testPolicyRules = [ { allow: true, protocol: 'http:' }, { allow: false, host: 'via.placeholder.com' }, @@ -24,9 +22,9 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ]; return { - servers: apiConfig.get('servers'), + ...apiConfig.getAll(), junit: { reportName: 'X-Pack Reporting API Integration Tests' }, - testFiles: [require.resolve('./reporting_and_security')], + testFiles: [resolve(__dirname, './reporting_and_security')], services: { ...apiConfig.get('services'), reportingAPI: ReportingAPIProvider, @@ -34,22 +32,11 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { kbnTestServer: { ...apiConfig.get('kbnTestServer'), serverArgs: [ - ...functionalConfig.get('kbnTestServer.serverArgs'), - - `--elasticsearch.hosts=${formatUrl(esTestConfig.getUrlParts())}`, - `--elasticsearch.password=${kibanaServerTestUser.password}`, - `--elasticsearch.username=${kibanaServerTestUser.username}`, - `--logging.json=false`, - `--server.maxPayloadBytes=1679958`, - `--server.port=${kbnTestConfig.getPort()}`, + ...apiConfig.get('kbnTestServer.serverArgs'), + `--xpack.reporting.capture.networkPolicy.rules=${JSON.stringify(testPolicyRules)}`, `--xpack.reporting.capture.maxAttempts=1`, `--xpack.reporting.csv.maxSizeBytes=6000`, - `--xpack.reporting.queue.pollInterval=3000`, - `--xpack.security.session.idleTimeout=3600000`, - `--xpack.reporting.capture.networkPolicy.rules=${JSON.stringify(testPolicyRules)}`, ], }, - esArchiver: apiConfig.get('esArchiver'), - esTestCluster: apiConfig.get('esTestCluster'), }; } diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/__snapshots__/csv_searchsource_immediate.snap b/x-pack/test/reporting_api_integration/reporting_and_security/__snapshots__/download_csv_dashboard.snap similarity index 100% rename from x-pack/test/reporting_api_integration/reporting_and_security/__snapshots__/csv_searchsource_immediate.snap rename to x-pack/test/reporting_api_integration/reporting_and_security/__snapshots__/download_csv_dashboard.snap diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/constants.ts b/x-pack/test/reporting_api_integration/reporting_and_security/constants.ts deleted file mode 100644 index f765046bce9b13..00000000000000 --- a/x-pack/test/reporting_api_integration/reporting_and_security/constants.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { REPO_ROOT } from '@kbn/utils'; -import path from 'path'; - -export const OSS_KIBANA_ARCHIVE_PATH = path.resolve( - REPO_ROOT, - 'test/functional/fixtures/es_archiver/dashboard/current/kibana' -); -export const OSS_DATA_ARCHIVE_PATH = path.resolve( - REPO_ROOT, - 'test/functional/fixtures/es_archiver/dashboard/current/data' -); diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/csv_searchsource_immediate.ts b/x-pack/test/reporting_api_integration/reporting_and_security/download_csv_dashboard.ts similarity index 94% rename from x-pack/test/reporting_api_integration/reporting_and_security/csv_searchsource_immediate.ts rename to x-pack/test/reporting_api_integration/reporting_and_security/download_csv_dashboard.ts index f381bc1edd28ed..7f642f171b9fca 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/csv_searchsource_immediate.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/download_csv_dashboard.ts @@ -38,15 +38,14 @@ export default function ({ getService }: FtrProviderContext) { 'dateFormat:tz': 'UTC', defaultIndex: 'logstash-*', }); + await reportingAPI.initEcommerce(); }); after(async () => { + await reportingAPI.teardownEcommerce(); await reportingAPI.deleteAllReports(); }); it('Exports CSV with almost all fields when using fieldsFromSource', async () => { - await esArchiver.load('reporting/ecommerce'); - await esArchiver.load('reporting/ecommerce_kibana'); - const { status: resStatus, text: resText, @@ -145,15 +144,9 @@ export default function ({ getService }: FtrProviderContext) { expect(resStatus).to.eql(200); expect(resType).to.eql('text/csv'); expectSnapshot(resText).toMatch(); - - await esArchiver.unload('reporting/ecommerce'); - await esArchiver.unload('reporting/ecommerce_kibana'); }); it('Exports CSV with all fields when using defaults', async () => { - await esArchiver.load('reporting/ecommerce'); - await esArchiver.load('reporting/ecommerce_kibana'); - const { status: resStatus, text: resText, @@ -192,15 +185,9 @@ export default function ({ getService }: FtrProviderContext) { expect(resStatus).to.eql(200); expect(resType).to.eql('text/csv'); expectSnapshot(resText).toMatch(); - - await esArchiver.unload('reporting/ecommerce'); - await esArchiver.unload('reporting/ecommerce_kibana'); }); it('Logs the error explanation if the search query returns an error', async () => { - await esArchiver.load('reporting/ecommerce'); - await esArchiver.load('reporting/ecommerce_kibana'); - const { status: resStatus, text: resText } = (await generateAPI.getCSVFromSearchSource( getMockJobParams({ searchSource: { @@ -234,9 +221,6 @@ export default function ({ getService }: FtrProviderContext) { )) as supertest.Response; expect(resStatus).to.eql(500); expectSnapshot(resText).toMatch(); - - await esArchiver.unload('reporting/ecommerce'); - await esArchiver.unload('reporting/ecommerce_kibana'); }); describe('date formatting', () => { @@ -434,6 +418,9 @@ export default function ({ getService }: FtrProviderContext) { }); describe('validation', () => { + after(async () => { + await reportingAPI.deleteAllReports(); + }); it('Return a 404', async () => { const { body } = (await generateAPI.getCSVFromSearchSource( getMockJobParams({ @@ -451,8 +438,7 @@ export default function ({ getService }: FtrProviderContext) { }); it(`Searches large amount of data, stops at Max Size Reached`, async () => { - await esArchiver.load('reporting/ecommerce'); - await esArchiver.load('reporting/ecommerce_kibana'); + await reportingAPI.initEcommerce(); const { status: resStatus, @@ -504,8 +490,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resType).to.eql('text/csv'); expectSnapshot(resText).toMatch(); - await esArchiver.unload('reporting/ecommerce'); - await esArchiver.unload('reporting/ecommerce_kibana'); + await reportingAPI.teardownEcommerce(); }); }); }); diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/csv_job_params.ts b/x-pack/test/reporting_api_integration/reporting_and_security/generate_csv_discover.ts similarity index 97% rename from x-pack/test/reporting_api_integration/reporting_and_security/csv_job_params.ts rename to x-pack/test/reporting_api_integration/reporting_and_security/generate_csv_discover.ts index b3fa9ebe46f8cb..3370eb0bb398bf 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/csv_job_params.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/generate_csv_discover.ts @@ -7,7 +7,7 @@ import expect from '@kbn/expect'; import supertest from 'supertest'; -import { JOB_PARAMS_RISON_CSV_DEPRECATED } from '../fixtures'; +import { JOB_PARAMS_RISON_CSV_DEPRECATED } from '../services/fixtures'; import { FtrProviderContext } from '../ftr_provider_context'; // eslint-disable-next-line import/no-default-export diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/index.ts b/x-pack/test/reporting_api_integration/reporting_and_security/index.ts index b4e05e37d3fda2..78873f2097e80f 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/index.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/index.ts @@ -8,11 +8,20 @@ import { FtrProviderContext } from '../ftr_provider_context'; // eslint-disable-next-line import/no-default-export -export default function ({ loadTestFile }: FtrProviderContext) { +export default function ({ getService, loadTestFile }: FtrProviderContext) { describe('Reporting APIs', function () { this.tags('ciGroup2'); - loadTestFile(require.resolve('./csv_job_params')); - loadTestFile(require.resolve('./csv_searchsource_immediate')); + + before(async () => { + const reportingAPI = getService('reportingAPI'); + await reportingAPI.createDataAnalystRole(); + await reportingAPI.createDataAnalyst(); + await reportingAPI.createTestReportingUser(); + }); + + loadTestFile(require.resolve('./security_roles_privileges')); + loadTestFile(require.resolve('./download_csv_dashboard')); + loadTestFile(require.resolve('./generate_csv_discover')); loadTestFile(require.resolve('./network_policy')); loadTestFile(require.resolve('./spaces')); loadTestFile(require.resolve('./usage')); diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/security_roles_privileges.ts b/x-pack/test/reporting_api_integration/reporting_and_security/security_roles_privileges.ts new file mode 100644 index 00000000000000..4dbf1b6fa5ebb8 --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_and_security/security_roles_privileges.ts @@ -0,0 +1,192 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import supertest from 'supertest'; +import { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const reportingAPI = getService('reportingAPI'); + + describe('Security Roles and Privileges for Applications', () => { + before(async () => { + await reportingAPI.initEcommerce(); + }); + after(async () => { + await reportingAPI.teardownEcommerce(); + await reportingAPI.deleteAllReports(); + }); + + describe('Dashboard: CSV download file', () => { + it('does not allow user that does not have the role-based privilege', async () => { + const res = (await reportingAPI.downloadCsv( + reportingAPI.DATA_ANALYST_USERNAME, + reportingAPI.DATA_ANALYST_PASSWORD, + { + searchSource: { + query: { query: '', language: 'kuery' }, + index: '5193f870-d861-11e9-a311-0fa548c5f953', + filter: [], + }, + browserTimezone: 'UTC', + title: 'testfooyu78yt90-', + } as any + )) as supertest.Response; + expect(res.status).to.eql(403); + }); + + it('does allow user with the role privilege', async () => { + const res = (await reportingAPI.downloadCsv( + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD, + { + searchSource: { + query: { query: '', language: 'kuery' }, + index: '5193f870-d861-11e9-a311-0fa548c5f953', + filter: [], + }, + browserTimezone: 'UTC', + title: 'testfooyu78yt90-', + } as any + )) as supertest.Response; + expect(res.status).to.eql(200); + }); + }); + + describe('Dashboard: Generate PDF report', () => { + it('does not allow user that does not have the role-based privilege', async () => { + const res = await reportingAPI.generatePdf( + reportingAPI.DATA_ANALYST_USERNAME, + reportingAPI.DATA_ANALYST_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF disallowed', + layout: { id: 'preserve' }, + relativeUrls: ['/fooyou'], + objectType: 'dashboard', + } + ); + expect(res.status).to.eql(403); + }); + + it('does allow user with the role-based privilege', async () => { + const res = await reportingAPI.generatePdf( + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF allowed', + layout: { id: 'preserve' }, + relativeUrls: ['/fooyou'], + objectType: 'dashboard', + } + ); + expect(res.status).to.eql(200); + }); + }); + + describe('Visualize: Generate PDF report', () => { + it('does not allow user that does not have the role-based privilege', async () => { + const res = await reportingAPI.generatePdf( + reportingAPI.DATA_ANALYST_USERNAME, + reportingAPI.DATA_ANALYST_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF disallowed', + layout: { id: 'preserve' }, + relativeUrls: ['/fooyou'], + objectType: 'visualization', + } + ); + expect(res.status).to.eql(403); + }); + + it('does allow user with the role-based privilege', async () => { + const res = await reportingAPI.generatePdf( + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF allowed', + layout: { id: 'preserve' }, + relativeUrls: ['/fooyou'], + objectType: 'visualization', + } + ); + expect(res.status).to.eql(200); + }); + }); + + describe('Canvas: Generate PDF report', () => { + it('does not allow user that does not have the role-based privilege', async () => { + const res = await reportingAPI.generatePdf( + reportingAPI.DATA_ANALYST_USERNAME, + reportingAPI.DATA_ANALYST_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF disallowed', + layout: { id: 'preserve' }, + relativeUrls: ['/fooyou'], + objectType: 'canvas', + } + ); + expect(res.status).to.eql(403); + }); + + it('does allow user with the role-based privilege', async () => { + const res = await reportingAPI.generatePdf( + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF allowed', + layout: { id: 'preserve' }, + relativeUrls: ['/fooyou'], + objectType: 'canvas', + } + ); + expect(res.status).to.eql(200); + }); + }); + + describe('Discover: Generate CSV report', () => { + it('does not allow user that does not have the role-based privilege', async () => { + const res = await reportingAPI.generateCsv( + reportingAPI.DATA_ANALYST_USERNAME, + reportingAPI.DATA_ANALYST_PASSWORD, + { + browserTimezone: 'UTC', + searchSource: {}, + objectType: 'search', + title: 'test disallowed', + } + ); + expect(res.status).to.eql(403); + }); + + it('does allow user with the role-based privilege', async () => { + const res = await reportingAPI.generateCsv( + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD, + { + browserTimezone: 'UTC', + title: 'allowed search', + objectType: 'search', + searchSource: { + version: true, + fields: [{ field: '*', include_unmapped: 'true' }], + index: '5193f870-d861-11e9-a311-0fa548c5f953', + } as any, + columns: [], + } + ); + expect(res.status).to.eql(200); + }); + }); + }); +} diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/usage.ts b/x-pack/test/reporting_api_integration/reporting_and_security/usage.ts index 2a6bf95023fb44..a69534cfc4df74 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/usage.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/usage.ts @@ -6,10 +6,20 @@ */ import expect from '@kbn/expect'; +import { REPO_ROOT } from '@kbn/utils'; +import path from 'path'; import { FtrProviderContext } from '../ftr_provider_context'; -import * as GenerationUrls from '../generation_urls'; -import { ReportingUsageStats } from '../services'; -import { OSS_DATA_ARCHIVE_PATH, OSS_KIBANA_ARCHIVE_PATH } from './constants'; +import * as GenerationUrls from '../services/generation_urls'; +import { ReportingUsageStats } from '../services/usage'; + +const OSS_KIBANA_ARCHIVE_PATH = path.resolve( + REPO_ROOT, + 'test/functional/fixtures/es_archiver/dashboard/current/kibana' +); +const OSS_DATA_ARCHIVE_PATH = path.resolve( + REPO_ROOT, + 'test/functional/fixtures/es_archiver/dashboard/current/data' +); interface UsageStats { reporting: ReportingUsageStats; @@ -20,6 +30,7 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const reportingAPI = getService('reportingAPI'); + const retry = getService('retry'); const usageAPI = getService('usageAPI'); describe('Usage', () => { @@ -46,7 +57,10 @@ export default function ({ getService }: FtrProviderContext) { let usage: UsageStats; before(async () => { - usage = (await usageAPI.getUsageStats()) as UsageStats; + await retry.try(async () => { + // use retry for stability - usage API could return 503 + usage = (await usageAPI.getUsageStats()) as UsageStats; + }); }); it('shows reporting as available and enabled', async () => { diff --git a/x-pack/test/reporting_api_integration/reporting_without_security.config.ts b/x-pack/test/reporting_api_integration/reporting_without_security.config.ts index 20f9ff1b10592f..b962ab30876a51 100644 --- a/x-pack/test/reporting_api_integration/reporting_without_security.config.ts +++ b/x-pack/test/reporting_api_integration/reporting_without_security.config.ts @@ -5,24 +5,15 @@ * 2.0. */ -// @ts-expect-error https://github.com/elastic/kibana/issues/95679 -import { esTestConfig, kbnTestConfig } from '@kbn/test'; import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; -import { format as formatUrl } from 'url'; -import { pageObjects } from '../functional/page_objects'; // Reporting APIs depend on UI functionality -import { services } from './services'; export default async function ({ readConfigFile }: FtrConfigProviderContext) { - const apiConfig = await readConfigFile(require.resolve('../api_integration/config')); + const apiConfig = await readConfigFile(require.resolve('./reporting_and_security.config')); return { - apps: { reporting: { pathname: '/app/management/insightsAndAlerting/reporting' } }, - servers: apiConfig.get('servers'), - junit: { reportName: 'X-Pack Reporting Without Security API Integration Tests' }, + ...apiConfig.getAll(), + junit: { reportName: 'X-Pack Reporting API Integration Tests Without Security Enabled' }, testFiles: [require.resolve('./reporting_without_security')], - services, - pageObjects, - esArchiver: apiConfig.get('esArchiver'), esTestCluster: { ...apiConfig.get('esTestCluster'), serverArgs: [ @@ -33,15 +24,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { }, kbnTestServer: { ...apiConfig.get('kbnTestServer'), - serverArgs: [ - `--elasticsearch.hosts=${formatUrl(esTestConfig.getUrlParts())}`, - `--logging.json=false`, - `--server.maxPayloadBytes=1679958`, - `--server.port=${kbnTestConfig.getPort()}`, - `--xpack.reporting.capture.maxAttempts=1`, - `--xpack.reporting.csv.maxSizeBytes=2850`, - `--xpack.security.enabled=false`, - ], + serverArgs: [...apiConfig.get('kbnTestServer.serverArgs'), `--xpack.security.enabled=false`], }, }; } diff --git a/x-pack/test/reporting_api_integration/reporting_without_security/index.ts b/x-pack/test/reporting_api_integration/reporting_without_security/index.ts index eb0a349df7d3e0..15960e45d4a62a 100644 --- a/x-pack/test/reporting_api_integration/reporting_without_security/index.ts +++ b/x-pack/test/reporting_api_integration/reporting_without_security/index.ts @@ -9,9 +9,8 @@ import { FtrProviderContext } from '../ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default function ({ loadTestFile }: FtrProviderContext) { - describe('Reporting APIs', function () { + describe('Reporting API Integration Tests with Security disabled', function () { this.tags('ciGroup13'); loadTestFile(require.resolve('./job_apis')); - loadTestFile(require.resolve('./management')); }); } diff --git a/x-pack/test/reporting_api_integration/reporting_without_security/job_apis.ts b/x-pack/test/reporting_api_integration/reporting_without_security/job_apis.ts index 8d827f02dfd16f..194a3d6d1f5bc0 100644 --- a/x-pack/test/reporting_api_integration/reporting_without_security/job_apis.ts +++ b/x-pack/test/reporting_api_integration/reporting_without_security/job_apis.ts @@ -7,7 +7,7 @@ import expect from '@kbn/expect'; import { forOwn } from 'lodash'; -import { JOB_PARAMS_RISON_CSV_DEPRECATED } from '../fixtures'; +import { JOB_PARAMS_RISON_CSV_DEPRECATED } from '../services/fixtures'; import { FtrProviderContext } from '../ftr_provider_context'; // eslint-disable-next-line import/no-default-export @@ -16,7 +16,7 @@ export default function ({ getService }: FtrProviderContext) { const supertestNoAuth = getService('supertestWithoutAuth'); const reportingAPI = getService('reportingAPI'); - describe('Job Listing APIs, Without Security', () => { + describe('Job Listing APIs', () => { before(async () => { await esArchiver.load('reporting/logs'); await esArchiver.load('logstash_functional'); diff --git a/x-pack/test/reporting_api_integration/fixtures.ts b/x-pack/test/reporting_api_integration/services/fixtures.ts similarity index 100% rename from x-pack/test/reporting_api_integration/fixtures.ts rename to x-pack/test/reporting_api_integration/services/fixtures.ts diff --git a/x-pack/test/reporting_api_integration/generation_urls.ts b/x-pack/test/reporting_api_integration/services/generation_urls.ts similarity index 100% rename from x-pack/test/reporting_api_integration/generation_urls.ts rename to x-pack/test/reporting_api_integration/services/generation_urls.ts diff --git a/x-pack/test/reporting_api_integration/services/index.ts b/x-pack/test/reporting_api_integration/services/index.ts new file mode 100644 index 00000000000000..c0c3da4dd6ba15 --- /dev/null +++ b/x-pack/test/reporting_api_integration/services/index.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { services as xpackServices } from '../../functional/services'; +import { services as apiIntegrationServices } from '../../api_integration/services'; +import { FtrProviderContext } from '../ftr_provider_context'; +import { createUsageServices } from './usage'; +import { createScenarios } from './scenarios'; + +export function ReportingAPIProvider(context: FtrProviderContext) { + return { + ...createScenarios(context), + ...createUsageServices(context), + }; +} + +export const services = { + ...xpackServices, + supertestWithoutAuth: apiIntegrationServices.supertestWithoutAuth, + usageAPI: apiIntegrationServices.usageAPI, + reportingAPI: ReportingAPIProvider, +}; diff --git a/x-pack/test/reporting_api_integration/services/scenarios.ts b/x-pack/test/reporting_api_integration/services/scenarios.ts new file mode 100644 index 00000000000000..d13deac3578baa --- /dev/null +++ b/x-pack/test/reporting_api_integration/services/scenarios.ts @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import rison, { RisonValue } from 'rison-node'; +import { JobParamsCSV } from '../../../plugins/reporting/server/export_types/csv_searchsource/types'; +import { JobParamsDownloadCSV } from '../../../plugins/reporting/server/export_types/csv_searchsource_immediate/types'; +import { JobParamsPNG } from '../../../plugins/reporting/server/export_types/png/types'; +import { JobParamsPDF } from '../../../plugins/reporting/server/export_types/printable_pdf/types'; +import { FtrProviderContext } from '../ftr_provider_context'; + +function removeWhitespace(str: string) { + return str.replace(/\s/g, ''); +} + +export function createScenarios({ getService }: Pick) { + const security = getService('security'); + const esArchiver = getService('esArchiver'); + const log = getService('log'); + const supertest = getService('supertest'); + const esSupertest = getService('esSupertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const retry = getService('retry'); + + const DATA_ANALYST_USERNAME = 'data_analyst'; + const DATA_ANALYST_PASSWORD = 'data_analyst-password'; + const REPORTING_USER_USERNAME = 'reporting_user'; + const REPORTING_USER_PASSWORD = 'reporting_user-password'; + + const initEcommerce = async () => { + await esArchiver.load('reporting/ecommerce'); + await esArchiver.load('reporting/ecommerce_kibana'); + }; + const teardownEcommerce = async () => { + await esArchiver.unload('reporting/ecommerce'); + await esArchiver.unload('reporting/ecommerce_kibana'); + await deleteAllReports(); + }; + + const createDataAnalystRole = async () => { + await security.role.create('data_analyst', { + metadata: {}, + elasticsearch: { + cluster: [], + indices: [ + { + names: ['ecommerce'], + privileges: ['read', 'view_index_metadata'], + allow_restricted_indices: false, + }, + ], + run_as: [], + }, + kibana: [{ base: ['read'], feature: {}, spaces: ['*'] }], + }); + }; + + const createDataAnalyst = async () => { + await security.user.create('data_analyst', { + password: 'data_analyst-password', + roles: ['data_analyst'], + full_name: 'Data Analyst User', + }); + }; + + const createTestReportingUser = async () => { + await security.user.create('reporting_user', { + password: 'reporting_user-password', + roles: ['data_analyst', 'reporting_user'], + full_name: 'Reporting User', + }); + }; + + const downloadCsv = async (username: string, password: string, job: JobParamsDownloadCSV) => { + return await supertestWithoutAuth + .post(`/api/reporting/v1/generate/immediate/csv_searchsource`) + .auth(username, password) + .set('kbn-xsrf', 'xxx') + .send(job); + }; + const generatePdf = async (username: string, password: string, job: JobParamsPDF) => { + const jobParams = rison.encode((job as object) as RisonValue); + return await supertestWithoutAuth + .post(`/api/reporting/generate/printablePdf`) + .auth(username, password) + .set('kbn-xsrf', 'xxx') + .send({ jobParams }); + }; + const generatePng = async (username: string, password: string, job: JobParamsPNG) => { + const jobParams = rison.encode((job as object) as RisonValue); + return await supertestWithoutAuth + .post(`/api/reporting/generate/png`) + .auth(username, password) + .set('kbn-xsrf', 'xxx') + .send({ jobParams }); + }; + const generateCsv = async (username: string, password: string, job: JobParamsCSV) => { + const jobParams = rison.encode((job as object) as RisonValue); + return await supertestWithoutAuth + .post(`/api/reporting/generate/csv_searchsource`) + .auth(username, password) + .set('kbn-xsrf', 'xxx') + .send({ jobParams }); + }; + + const postJob = async (apiPath: string): Promise => { + log.debug(`ReportingAPI.postJob(${apiPath})`); + const { body } = await supertest + .post(removeWhitespace(apiPath)) + .set('kbn-xsrf', 'xxx') + .expect(200); + return body.path; + }; + + const postJobJSON = async (apiPath: string, jobJSON: object = {}): Promise => { + log.debug(`ReportingAPI.postJobJSON((${apiPath}): ${JSON.stringify(jobJSON)})`); + const { body } = await supertest.post(apiPath).set('kbn-xsrf', 'xxx').send(jobJSON); + return body.path; + }; + + const deleteAllReports = async () => { + log.debug('ReportingAPI.deleteAllReports'); + + // ignores 409 errs and keeps retrying + await retry.tryForTime(5000, async () => { + await esSupertest + .post('/.reporting*/_delete_by_query') + .send({ query: { match_all: {} } }) + .expect(200); + }); + }; + + return { + initEcommerce, + teardownEcommerce, + DATA_ANALYST_USERNAME, + DATA_ANALYST_PASSWORD, + REPORTING_USER_USERNAME, + REPORTING_USER_PASSWORD, + createDataAnalystRole, + createDataAnalyst, + createTestReportingUser, + downloadCsv, + generatePdf, + generatePng, + generateCsv, + postJob, + postJobJSON, + deleteAllReports, + }; +} diff --git a/x-pack/test/reporting_api_integration/services.ts b/x-pack/test/reporting_api_integration/services/usage.ts similarity index 53% rename from x-pack/test/reporting_api_integration/services.ts rename to x-pack/test/reporting_api_integration/services/usage.ts index b451a6b65fc913..ababbbf03e4c10 100644 --- a/x-pack/test/reporting_api_integration/services.ts +++ b/x-pack/test/reporting_api_integration/services/usage.ts @@ -6,10 +6,7 @@ */ import expect from '@kbn/expect'; -import { indexTimestamp } from '../../plugins/reporting/server/lib/store/index_timestamp'; -import { services as xpackServices } from '../functional/services'; -import { services as apiIntegrationServices } from '../api_integration/services'; -import { FtrProviderContext } from './ftr_provider_context'; +import { FtrProviderContext } from '../ftr_provider_context'; interface PDFAppCounts { app: { @@ -38,15 +35,9 @@ interface UsageStats { reporting: ReportingUsageStats; } -function removeWhitespace(str: string) { - return str.replace(/\s/g, ''); -} - -export function ReportingAPIProvider({ getService }: FtrProviderContext) { +export function createUsageServices({ getService }: FtrProviderContext) { const log = getService('log'); const supertest = getService('supertest'); - const esSupertest = getService('esSupertest'); - const retry = getService('retry'); return { async waitForJobToFinish(downloadReportPath: string) { @@ -84,69 +75,6 @@ export function ReportingAPIProvider({ getService }: FtrProviderContext) { ); }, - async postJob(apiPath: string): Promise { - log.debug(`ReportingAPI.postJob(${apiPath})`); - const { body } = await supertest - .post(removeWhitespace(apiPath)) - .set('kbn-xsrf', 'xxx') - .expect(200); - return body.path; - }, - - async postJobJSON(apiPath: string, jobJSON: object = {}): Promise { - log.debug(`ReportingAPI.postJobJSON((${apiPath}): ${JSON.stringify(jobJSON)})`); - const { body } = await supertest.post(apiPath).set('kbn-xsrf', 'xxx').send(jobJSON); - return body.path; - }, - - /** - * - * @return {Promise} A function to call to clean up the index alias that was added. - */ - async coerceReportsIntoExistingIndex(indexName: string) { - log.debug(`ReportingAPI.coerceReportsIntoExistingIndex(${indexName})`); - - // Adding an index alias coerces the report to be generated on an existing index which means any new - // index schema won't be applied. This is important if a point release updated the schema. Reports may still - // be inserted into an existing index before the new schema is applied. - const timestampForIndex = indexTimestamp('week', '.'); - await esSupertest - .post('/_aliases') - .send({ - actions: [ - { - add: { index: indexName, alias: `.reporting-${timestampForIndex}` }, - }, - ], - }) - .expect(200); - - return async () => { - await esSupertest - .post('/_aliases') - .send({ - actions: [ - { - remove: { index: indexName, alias: `.reporting-${timestampForIndex}` }, - }, - ], - }) - .expect(200); - }; - }, - - async deleteAllReports() { - log.debug('ReportingAPI.deleteAllReports'); - - // ignores 409 errs and keeps retrying - await retry.tryForTime(5000, async () => { - await esSupertest - .post('/.reporting*/_delete_by_query') - .send({ query: { match_all: {} } }) - .expect(200); - }); - }, - expectRecentPdfAppStats(stats: UsageStats, app: string, count: number) { expect(stats.reporting.last_7_days.printable_pdf.app[app]).to.be(count); }, @@ -180,10 +108,3 @@ export function ReportingAPIProvider({ getService }: FtrProviderContext) { }, }; } - -export const services = { - ...xpackServices, - supertestWithoutAuth: apiIntegrationServices.supertestWithoutAuth, - usageAPI: apiIntegrationServices.usageAPI, - reportingAPI: ReportingAPIProvider, -}; diff --git a/x-pack/test/reporting_functional/ftr_provider_context.d.ts b/x-pack/test/reporting_functional/ftr_provider_context.d.ts new file mode 100644 index 00000000000000..58ebd710861303 --- /dev/null +++ b/x-pack/test/reporting_functional/ftr_provider_context.d.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; +import { pageObjects } from '../functional/page_objects'; +import { services } from './services'; + +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/reporting_functional/reporting_and_security.config.ts b/x-pack/test/reporting_functional/reporting_and_security.config.ts new file mode 100644 index 00000000000000..1f9ec5754e0bd8 --- /dev/null +++ b/x-pack/test/reporting_functional/reporting_and_security.config.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import { resolve } from 'path'; +import { ReportingAPIProvider } from '../reporting_api_integration/services'; +import { ReportingFunctionalProvider } from './services'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const functionalConfig = await readConfigFile(require.resolve('../functional/config')); // Reporting API tests need a fully working UI + const apiConfig = await readConfigFile(require.resolve('../api_integration/config')); + + return { + ...apiConfig.getAll(), + ...functionalConfig.getAll(), + junit: { reportName: 'X-Pack Reporting Functional Tests' }, + testFiles: [resolve(__dirname, './reporting_and_security')], + kbnTestServer: { + ...functionalConfig.get('kbnTestServer'), + serverArgs: [ + ...functionalConfig.get('kbnTestServer.serverArgs'), + `--xpack.reporting.capture.maxAttempts=1`, + `--xpack.reporting.csv.maxSizeBytes=6000`, + ], + }, + services: { + ...apiConfig.get('services'), + ...functionalConfig.get('services'), + reportingAPI: ReportingAPIProvider, + reportingFunctional: ReportingFunctionalProvider, + }, + }; +} diff --git a/x-pack/test/reporting_functional/reporting_and_security/index.ts b/x-pack/test/reporting_functional/reporting_and_security/index.ts new file mode 100644 index 00000000000000..f3e01453b0a59b --- /dev/null +++ b/x-pack/test/reporting_functional/reporting_and_security/index.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService, loadTestFile }: FtrProviderContext) { + const security = getService('security'); + const createDataAnalystRole = async () => { + await security.role.create('data_analyst', { + metadata: {}, + elasticsearch: { + cluster: [], + indices: [ + { + names: ['ecommerce'], + privileges: ['read', 'view_index_metadata'], + allow_restricted_indices: false, + }, + ], + run_as: [], + }, + kibana: [{ base: ['all'], feature: {}, spaces: ['*'] }], + }); + }; + const createDataAnalyst = async () => { + await security.user.create('data_analyst', { + password: 'data_analyst-password', + roles: ['data_analyst', 'kibana_user'], + full_name: 'a kibana user called data_a', + }); + }; + const createReportingUser = async () => { + await security.user.create('reporting_user', { + password: 'reporting_user-password', + roles: ['reporting_user', 'data_analyst', 'kibana_user'], + full_name: 'a reporting user', + }); + }; + + describe('Reporting Functional Tests with Role-based Security configuration enabled', function () { + this.tags('ciGroup2'); + + before(async () => { + await createDataAnalystRole(); + await createDataAnalyst(); + await createReportingUser(); + }); + + loadTestFile(require.resolve('./security_roles_privileges')); + loadTestFile(require.resolve('./management')); + }); +} diff --git a/x-pack/test/reporting_functional/reporting_and_security/management.ts b/x-pack/test/reporting_functional/reporting_and_security/management.ts new file mode 100644 index 00000000000000..dba16c798d4ffb --- /dev/null +++ b/x-pack/test/reporting_functional/reporting_and_security/management.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService, getPageObjects }: FtrProviderContext) => { + const PageObjects = getPageObjects(['common', 'reporting', 'discover']); + + const testSubjects = getService('testSubjects'); + const reportingFunctional = getService('reportingFunctional'); + + describe('Access to Management > Reporting', () => { + before(async () => { + await reportingFunctional.initEcommerce(); + }); + after(async () => { + await reportingFunctional.teardownEcommerce(); + }); + + it('does not allow user that does not have reporting_user role', async () => { + await reportingFunctional.loginDataAnalyst(); + await PageObjects.common.navigateToApp('reporting'); + await testSubjects.missingOrFail('reportJobListing'); + }); + + it('does allow user with reporting_user role', async () => { + await reportingFunctional.loginReportingUser(); + await PageObjects.common.navigateToApp('reporting'); + await testSubjects.existOrFail('reportJobListing'); + }); + }); +}; diff --git a/x-pack/test/reporting_functional/reporting_and_security/security_roles_privileges.ts b/x-pack/test/reporting_functional/reporting_and_security/security_roles_privileges.ts new file mode 100644 index 00000000000000..76ccb014778568 --- /dev/null +++ b/x-pack/test/reporting_functional/reporting_and_security/security_roles_privileges.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; + +const DASHBOARD_TITLE = 'Ecom Dashboard'; +const SAVEDSEARCH_TITLE = 'Ecommerce Data'; +const VIS_TITLE = 'e-commerce pie chart'; +const CANVAS_TITLE = 'The Very Cool Workpad for PDF Tests'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const reportingFunctional = getService('reportingFunctional'); + + describe('Security with `reporting_user` built-in role', () => { + before(async () => { + await reportingFunctional.initEcommerce(); + }); + after(async () => { + await reportingFunctional.teardownEcommerce(); + }); + + describe('Dashboard: Download CSV file', () => { + it('does not allow user that does not have reporting_user role', async () => { + await reportingFunctional.loginDataAnalyst(); + await reportingFunctional.openSavedDashboard(DASHBOARD_TITLE); + await reportingFunctional.tryDashboardDownloadCsvFail('Ecommerce Data'); + }); + + it('does allow user with reporting_user role', async () => { + await reportingFunctional.loginDataAnalyst(); + await reportingFunctional.openSavedDashboard(DASHBOARD_TITLE); + await reportingFunctional.tryDashboardDownloadCsvSuccess('Ecommerce Data'); + }); + }); + + describe('Dashboard: Generate Screenshot', () => { + it('does not allow user that does not have reporting_user role', async () => { + await reportingFunctional.loginDataAnalyst(); + await reportingFunctional.openSavedDashboard(DASHBOARD_TITLE); + await reportingFunctional.tryGeneratePdfFail(); + }); + + it('does allow user with reporting_user role', async () => { + await reportingFunctional.loginReportingUser(); + await reportingFunctional.openSavedDashboard(DASHBOARD_TITLE); + await reportingFunctional.tryGeneratePdfSuccess(); + }); + }); + + describe('Discover: Generate CSV', () => { + it('does not allow user that does not have reporting_user role', async () => { + await reportingFunctional.loginDataAnalyst(); + await reportingFunctional.openSavedSearch(SAVEDSEARCH_TITLE); + await reportingFunctional.tryDiscoverCsvFail(); + }); + + it('does allow user with reporting_user role', async () => { + await reportingFunctional.loginReportingUser(); + await reportingFunctional.openSavedSearch(SAVEDSEARCH_TITLE); + await reportingFunctional.tryDiscoverCsvSuccess(); + }); + }); + + describe('Canvas: Generate PDF', () => { + const esArchiver = getService('esArchiver'); + const reportingApi = getService('reportingAPI'); + before('initialize tests', async () => { + await esArchiver.load('canvas/reports'); + }); + + after('teardown tests', async () => { + await esArchiver.unload('canvas/reports'); + await reportingApi.deleteAllReports(); + await reportingFunctional.initEcommerce(); + }); + + it('does not allow user that does not have reporting_user role', async () => { + await reportingFunctional.loginDataAnalyst(); + await reportingFunctional.openCanvasWorkpad(CANVAS_TITLE); + await reportingFunctional.tryGeneratePdfFail(); + }); + + it('does allow user with reporting_user role', async () => { + await reportingFunctional.loginReportingUser(); + await reportingFunctional.openCanvasWorkpad(CANVAS_TITLE); + await reportingFunctional.tryGeneratePdfSuccess(); + }); + }); + + describe('Visualize Editor: Generate Screenshot', () => { + it('does not allow user that does not have reporting_user role', async () => { + await reportingFunctional.loginDataAnalyst(); + await reportingFunctional.openSavedVisualization(VIS_TITLE); + await reportingFunctional.tryGeneratePdfFail(); + }); + + it('does allow user with reporting_user role', async () => { + await reportingFunctional.loginReportingUser(); + await reportingFunctional.openSavedVisualization(VIS_TITLE); + await reportingFunctional.tryGeneratePdfSuccess(); + }); + }); + }); +} diff --git a/x-pack/test/reporting_functional/reporting_without_security.config.ts b/x-pack/test/reporting_functional/reporting_without_security.config.ts new file mode 100644 index 00000000000000..b88c6115439536 --- /dev/null +++ b/x-pack/test/reporting_functional/reporting_without_security.config.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import { resolve } from 'path'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const reportingConfig = await readConfigFile(require.resolve('./reporting_and_security.config')); + + return { + ...reportingConfig.getAll(), + junit: { reportName: 'X-Pack Reporting Functional Tests Without Security Enabled' }, + testFiles: [resolve(__dirname, './reporting_without_security')], + kbnTestServer: { + ...reportingConfig.get('kbnTestServer'), + serverArgs: [ + ...reportingConfig.get('kbnTestServer.serverArgs'), + `--xpack.security.enabled=false`, + ], + }, + esTestCluster: { + ...reportingConfig.get('esTestCluster'), + serverArgs: [ + ...reportingConfig.get('esTestCluster.serverArgs'), + 'node.name=UnsecuredClusterNode01', + 'xpack.security.enabled=false', + ], + }, + }; +} diff --git a/x-pack/test/reporting_functional/reporting_without_security/index.ts b/x-pack/test/reporting_functional/reporting_without_security/index.ts new file mode 100644 index 00000000000000..d1801b7e3e2e6c --- /dev/null +++ b/x-pack/test/reporting_functional/reporting_without_security/index.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ loadTestFile, getService }: FtrProviderContext) { + describe('Reporting Functional Tests with Security disabled', function () { + this.tags('ciGroup2'); + loadTestFile(require.resolve('./management')); + }); +} diff --git a/x-pack/test/reporting_api_integration/reporting_without_security/management.ts b/x-pack/test/reporting_functional/reporting_without_security/management.ts similarity index 96% rename from x-pack/test/reporting_api_integration/reporting_without_security/management.ts rename to x-pack/test/reporting_functional/reporting_without_security/management.ts index f6db20c75639da..b116bb5fe201c0 100644 --- a/x-pack/test/reporting_api_integration/reporting_without_security/management.ts +++ b/x-pack/test/reporting_functional/reporting_without_security/management.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { JOB_PARAMS_ECOM_MARKDOWN } from '../fixtures'; +import { JOB_PARAMS_ECOM_MARKDOWN } from '../../reporting_api_integration/services/fixtures'; import { FtrProviderContext } from '../ftr_provider_context'; // eslint-disable-next-line import/no-default-export diff --git a/x-pack/test/reporting_functional/services/index.ts b/x-pack/test/reporting_functional/services/index.ts new file mode 100644 index 00000000000000..458ddc7c734201 --- /dev/null +++ b/x-pack/test/reporting_functional/services/index.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { services as apiServices } from '../../reporting_api_integration/services'; +import { FtrProviderContext } from '../ftr_provider_context'; +import { createScenarios } from './scenarios'; + +export function ReportingFunctionalProvider(context: FtrProviderContext) { + return createScenarios(context); +} + +export const services = { + ...apiServices, + reportingFunctional: ReportingFunctionalProvider, +}; diff --git a/x-pack/test/reporting_functional/services/scenarios.ts b/x-pack/test/reporting_functional/services/scenarios.ts new file mode 100644 index 00000000000000..a1387127ffc0ac --- /dev/null +++ b/x-pack/test/reporting_functional/services/scenarios.ts @@ -0,0 +1,165 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../ftr_provider_context'; +import { createScenarios as createAPIScenarios } from '../../reporting_api_integration/services/scenarios'; + +export function createScenarios( + context: Pick +) { + const { getService, getPageObjects } = context; + const log = getService('log'); + const testSubjects = getService('testSubjects'); + const dashboardPanelActions = getService('dashboardPanelActions'); + + const PageObjects = getPageObjects([ + 'reporting', + 'security', + 'common', + 'share', + 'visualize', + 'dashboard', + 'discover', + 'canvas', + ]); + const scenariosAPI = createAPIScenarios(context); + + const { + DATA_ANALYST_USERNAME, + DATA_ANALYST_PASSWORD, + REPORTING_USER_USERNAME, + REPORTING_USER_PASSWORD, + } = scenariosAPI; + + const loginDataAnalyst = async () => { + await PageObjects.security.forceLogout(); + await PageObjects.security.login(DATA_ANALYST_USERNAME, DATA_ANALYST_PASSWORD, { + expectSpaceSelector: false, + }); + }; + + const loginReportingUser = async () => { + await PageObjects.security.forceLogout(); + await PageObjects.security.login(REPORTING_USER_USERNAME, REPORTING_USER_PASSWORD, { + expectSpaceSelector: false, + }); + }; + + const openSavedVisualization = async (title: string) => { + log.debug(`Opening saved visualizatiton: ${title}`); + await PageObjects.common.navigateToApp('visualize'); + await PageObjects.visualize.openSavedVisualization(title); + }; + + const openSavedDashboard = async (title: string) => { + log.debug(`Opening saved dashboard: ${title}`); + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.loadSavedDashboard(title); + }; + + const openSavedSearch = async (title: string) => { + log.debug(`Opening saved search: ${title}`); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.loadSavedSearch(title); + }; + + const openCanvasWorkpad = async (title: string) => { + log.debug(`Opening saved canvas workpad: ${title}`); + await PageObjects.common.navigateToApp('canvas'); + await PageObjects.canvas.loadFirstWorkpad(title); + }; + + const getSavedSearchPanel = async (savedSearchTitle: string) => { + return await testSubjects.find(`embeddablePanelHeading-${savedSearchTitle.replace(' ', '')}`); + }; + const tryDashboardDownloadCsvFail = async (savedSearchTitle: string) => { + const savedSearchPanel = await getSavedSearchPanel(savedSearchTitle); + await dashboardPanelActions.toggleContextMenu(savedSearchPanel); + await dashboardPanelActions.clickContextMenuMoreItem(); + const actionItemTestSubj = 'embeddablePanelAction-downloadCsvReport'; + await testSubjects.existOrFail(actionItemTestSubj); + /* wait for the full panel to display or else the test runner could click the wrong option! */ await testSubjects.click( + actionItemTestSubj + ); + await testSubjects.existOrFail('downloadCsvFail'); + }; + const tryDashboardDownloadCsvNotAvailable = async (savedSearchTitle: string) => { + const savedSearchPanel = await getSavedSearchPanel(savedSearchTitle); + await dashboardPanelActions.toggleContextMenu(savedSearchPanel); + await dashboardPanelActions.clickContextMenuMoreItem(); + await testSubjects.missingOrFail('embeddablePanelAction-downloadCsvReport'); + }; + const tryDashboardDownloadCsvSuccess = async (savedSearchTitle: string) => { + const savedSearchPanel = await getSavedSearchPanel(savedSearchTitle); + await dashboardPanelActions.toggleContextMenu(savedSearchPanel); + await dashboardPanelActions.clickContextMenuMoreItem(); + const actionItemTestSubj = 'embeddablePanelAction-downloadCsvReport'; + await testSubjects.existOrFail(actionItemTestSubj); + /* wait for the full panel to display or else the test runner could click the wrong option! */ await testSubjects.click( + actionItemTestSubj + ); + await testSubjects.existOrFail('csvDownloadStarted'); /* validate toast panel */ + }; + const tryDiscoverCsvFail = async () => { + await PageObjects.reporting.openCsvReportingPanel(); + await PageObjects.reporting.clickGenerateReportButton(); + const queueReportError = await PageObjects.reporting.getQueueReportError(); + expect(queueReportError).to.be(true); + }; + const tryDiscoverCsvNotAvailable = async () => { + await PageObjects.share.clickShareTopNavButton(); + await testSubjects.missingOrFail('sharePanel-CSVReports'); + }; + const tryDiscoverCsvSuccess = async () => { + await PageObjects.reporting.openCsvReportingPanel(); + expect(await PageObjects.reporting.canReportBeCreated()).to.be(true); + }; + const tryGeneratePdfFail = async () => { + await PageObjects.reporting.openPdfReportingPanel(); + await PageObjects.reporting.clickGenerateReportButton(); + const queueReportError = await PageObjects.reporting.getQueueReportError(); + expect(queueReportError).to.be(true); + }; + const tryGeneratePdfNotAvailable = async () => { + PageObjects.share.clickShareTopNavButton(); + await testSubjects.missingOrFail(`sharePanel-PDFReports`); + }; + const tryGeneratePdfSuccess = async () => { + await PageObjects.reporting.openPdfReportingPanel(); + expect(await PageObjects.reporting.canReportBeCreated()).to.be(true); + }; + const tryGeneratePngSuccess = async () => { + await PageObjects.reporting.openPngReportingPanel(); + expect(await PageObjects.reporting.canReportBeCreated()).to.be(true); + }; + const tryReportsNotAvailable = async () => { + await PageObjects.share.clickShareTopNavButton(); + await testSubjects.missingOrFail('sharePanel-Reports'); + }; + + return { + ...scenariosAPI, + openSavedVisualization, + openSavedDashboard, + openSavedSearch, + openCanvasWorkpad, + tryDashboardDownloadCsvFail, + tryDashboardDownloadCsvNotAvailable, + tryDashboardDownloadCsvSuccess, + tryDiscoverCsvFail, + tryDiscoverCsvNotAvailable, + tryDiscoverCsvSuccess, + tryGeneratePdfFail, + tryGeneratePdfNotAvailable, + tryGeneratePdfSuccess, + tryGeneratePngSuccess, + tryReportsNotAvailable, + loginDataAnalyst, + loginReportingUser, + }; +} From 813681eb08d0b6659b5ec5f72bb7cf83397ae120 Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Wed, 14 Apr 2021 12:21:46 -0400 Subject: [PATCH 82/90] [Upgrade Assistant] Redesign overview page (#95346) --- ...-plugin-core-public.doclinksstart.links.md | 1 + .../public/doc_links/doc_links_service.ts | 3 + src/core/public/public.api.md | 1 + .../translations/translations/ja-JP.json | 41 --- .../translations/translations/zh-CN.json | 41 --- x-pack/plugins/upgrade_assistant/kibana.json | 2 +- .../public/application/app.tsx | 39 ++- .../public/application/app_context.tsx | 5 +- .../application/components/error_banner.tsx | 49 --- .../__snapshots__/filter_bar.test.tsx.snap | 14 +- .../es_deprecations/deprecation_tab.tsx | 222 ------------- .../deprecation_tab_content.tsx | 138 +++++++++ .../es_deprecations/es_deprecation_errors.tsx | 50 +++ .../es_deprecations/es_deprecations.tsx | 212 +++++++++++++ .../es_deprecations/filter_bar.test.tsx | 4 +- .../components/es_deprecations/filter_bar.tsx | 56 ++-- .../components/es_deprecations/index.ts | 2 +- .../overview/deprecation_logging_toggle.tsx | 55 ++-- .../components/overview/es_stats.tsx | 129 ++++++++ .../components/overview/es_stats_error.tsx | 84 +++++ .../application/components/overview/index.ts | 2 +- .../components/overview/overview.tsx | 157 +++++++--- .../application/components/overview/steps.tsx | 293 ------------------ .../application/components/page_content.tsx | 44 --- .../public/application/components/tabs.tsx | 184 ----------- .../public/application/components/types.ts | 7 +- .../public/application/lib/breadcrumbs.ts | 66 ++++ .../application/lib/es_deprecation_errors.ts | 59 ++++ .../application/mount_management_section.ts | 10 +- .../public/application/render_app.tsx | 2 - .../public/shared_imports.ts | 1 + .../helpers/indices.helpers.ts | 16 +- .../helpers/overview.helpers.ts | 25 +- .../helpers/setup_environment.tsx | 11 +- .../tests_client_integration/indices.test.ts | 71 ++++- .../tests_client_integration/overview.test.ts | 237 +++++++------- .../accessibility/apps/upgrade_assistant.ts | 26 +- 37 files changed, 1217 insertions(+), 1142 deletions(-) delete mode 100644 x-pack/plugins/upgrade_assistant/public/application/components/error_banner.tsx delete mode 100644 x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_tab.tsx create mode 100644 x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_tab_content.tsx create mode 100644 x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/es_deprecation_errors.tsx create mode 100644 x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/es_deprecations.tsx create mode 100644 x-pack/plugins/upgrade_assistant/public/application/components/overview/es_stats.tsx create mode 100644 x-pack/plugins/upgrade_assistant/public/application/components/overview/es_stats_error.tsx delete mode 100644 x-pack/plugins/upgrade_assistant/public/application/components/overview/steps.tsx delete mode 100644 x-pack/plugins/upgrade_assistant/public/application/components/page_content.tsx delete mode 100644 x-pack/plugins/upgrade_assistant/public/application/components/tabs.tsx create mode 100644 x-pack/plugins/upgrade_assistant/public/application/lib/breadcrumbs.ts create mode 100644 x-pack/plugins/upgrade_assistant/public/application/lib/es_deprecation_errors.ts diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index 01079bdf03d0cd..535bd8f11236df 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -108,6 +108,7 @@ readonly links: { }; readonly addData: string; readonly kibana: string; + readonly upgradeAssistant: string; readonly elasticsearch: Record; readonly siem: { readonly guide: string; diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 1bff91f15a150e..4220d3e490f63a 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -130,6 +130,7 @@ export class DocLinksService { }, addData: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/connect-to-elasticsearch.html`, kibana: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/index.html`, + upgradeAssistant: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/upgrade-assistant.html`, elasticsearch: { docsBase: `${ELASTICSEARCH_DOCS}`, asyncSearch: `${ELASTICSEARCH_DOCS}async-search-intro.html`, @@ -181,6 +182,7 @@ export class DocLinksService { scriptParameters: `${ELASTICSEARCH_DOCS}modules-scripting-using.html#prefer-params`, transportSettings: `${ELASTICSEARCH_DOCS}modules-transport.html`, typesRemoval: `${ELASTICSEARCH_DOCS}removal-of-types.html`, + deprecationLogging: `${ELASTICSEARCH_DOCS}logging.html#deprecation-logging`, }, siem: { guide: `${ELASTIC_WEBSITE_URL}guide/en/security/${DOC_LINK_VERSION}/index.html`, @@ -495,6 +497,7 @@ export interface DocLinksStart { }; readonly addData: string; readonly kibana: string; + readonly upgradeAssistant: string; readonly elasticsearch: Record; readonly siem: { readonly guide: string; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 88e4b0448a7be8..661ac51c4983c6 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -590,6 +590,7 @@ export interface DocLinksStart { }; readonly addData: string; readonly kibana: string; + readonly upgradeAssistant: string; readonly elasticsearch: Record; readonly siem: { readonly guide: string; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 4ec86a71dcb2aa..933bf512bdda03 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -22593,14 +22593,9 @@ "xpack.uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatErrorMessage": "無効な形式:{message}", "xpack.uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatGeneralErrorMessage": "無効なフォーマット。例:{exampleUrl}", "xpack.upgradeAssistant.appTitle": "{version} アップグレードアシスタント", - "xpack.upgradeAssistant.checkupTab.backUpCallout.calloutBody.calloutDetail": "{snapshotRestoreDocsButton} でデータをバックアップします。", - "xpack.upgradeAssistant.checkupTab.backUpCallout.calloutBody.snapshotRestoreDocsButtonLabel": "API のスナップショットと復元", - "xpack.upgradeAssistant.checkupTab.backUpCallout.calloutTitle": "今すぐインデックをバックアップ", "xpack.upgradeAssistant.checkupTab.changeFiltersShowMoreLabel": "より多く表示させるにはフィルターを変更します。", - "xpack.upgradeAssistant.checkupTab.clusterTabLabel": "クラスター", "xpack.upgradeAssistant.checkupTab.controls.collapseAllButtonLabel": "すべて縮小", "xpack.upgradeAssistant.checkupTab.controls.expandAllButtonLabel": "すべて拡張", - "xpack.upgradeAssistant.checkupTab.controls.filterBar.allButtonLabel": "すべて", "xpack.upgradeAssistant.checkupTab.controls.filterBar.criticalButtonLabel": "致命的", "xpack.upgradeAssistant.checkupTab.controls.filterErrorMessageLabel": "フィルター無効:{searchTermError}", "xpack.upgradeAssistant.checkupTab.controls.groupByBar.byIndexLabel": "インデックス別", @@ -22615,12 +22610,9 @@ "xpack.upgradeAssistant.checkupTab.deprecations.indexTable.indexColumnLabel": "インデックス", "xpack.upgradeAssistant.checkupTab.deprecations.warningActionTooltip": "アップグレード前にこの問題を解決することをお勧めしますが、必須ではありません。", "xpack.upgradeAssistant.checkupTab.deprecations.warningLabel": "警告", - "xpack.upgradeAssistant.checkupTab.indexLabel": "インデックス", - "xpack.upgradeAssistant.checkupTab.indicesTabLabel": "インデックス", "xpack.upgradeAssistant.checkupTab.noDeprecationsLabel": "説明がありません", "xpack.upgradeAssistant.checkupTab.noIssues.nextStepsDetail": "{overviewTabButton} で次のステップを確認してください。", "xpack.upgradeAssistant.checkupTab.noIssues.nextStepsDetail.overviewTabButtonLabel": "概要タブ", - "xpack.upgradeAssistant.checkupTab.noIssues.noIssuesLabel": "{strongCheckupLabel} の問題がありません。", "xpack.upgradeAssistant.checkupTab.noIssues.noIssuesTitle": "完璧です!", "xpack.upgradeAssistant.checkupTab.numDeprecationsShownLabel": "{total} 件中 {numShown} 件を表示中", "xpack.upgradeAssistant.checkupTab.reindexing.flyout.checklistStep.cancelButtonLabel": "キャンセル", @@ -22663,45 +22655,12 @@ "xpack.upgradeAssistant.checkupTab.reindexing.reindexButton.loadingLabel": "読み込み中…", "xpack.upgradeAssistant.checkupTab.reindexing.reindexButton.pausedLabel": "一時停止中", "xpack.upgradeAssistant.checkupTab.reindexing.reindexButton.reindexLabel": "再インデックス", - "xpack.upgradeAssistant.checkupTab.tabDetail": "これらの {strongCheckupLabel} 問題に対応する必要があります。Elasticsearch {nextEsVersion} へのアップグレード前に解決してください。", - "xpack.upgradeAssistant.forbiddenErrorCallout.calloutTitle": "このページを表示するための権限がありません。", - "xpack.upgradeAssistant.genericErrorCallout.calloutTitle": "チェックアップの結果を取得中にエラーが発生しました。", - "xpack.upgradeAssistant.overviewTab.overviewTabTitle": "概要", - "xpack.upgradeAssistant.overviewTab.steps.clusterStep.issuesRemainingStepTitle": "クラスターの問題を確認してください", - "xpack.upgradeAssistant.overviewTab.steps.clusterStep.noIssuesRemainingStepTitle": "クラスターの設定は準備完了です", - "xpack.upgradeAssistant.overviewTab.steps.clusterStep.noRemainingIssuesLabel": "廃止された設定は残っていません。", - "xpack.upgradeAssistant.overviewTab.steps.clusterStep.remainingIssuesDetail": "{numIssues} 件の問題が解決されました。", - "xpack.upgradeAssistant.overviewTab.steps.clusterStep.todo.clusterTabButtonLabel": "クラスタータブ", - "xpack.upgradeAssistant.overviewTab.steps.clusterStep.todo.todoDetail": "{clusterTabButton} に移動して廃止された設定を更新してください。", - "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.deprecationLogs.deprecationLogsDocButtonLabel": "廃止ログ", - "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.deprecationLogs.logsDetail": "{deprecationLogsDocButton} で、アプリケーションが {nextEsVersion} で利用できない機能を使用していないか確認してください。廃止ログを有効にする必要があるかもしれません。", - "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingLabel": "廃止ログを有効にしますか?", - "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.disabledLabel": "オフ", "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.enabledLabel": "オン", "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.errorLabel": "ログステータスを読み込めませんでした", - "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.stepTitle": "Elasticsearch の廃止ログを確認してください", - "xpack.upgradeAssistant.overviewTab.steps.indicesStep.issuesRemainingStepTitle": "インデックスの問題を確認してください", - "xpack.upgradeAssistant.overviewTab.steps.indicesStep.noIssuesRemainingStepTitle": "インデックスの設定は準備完了です", - "xpack.upgradeAssistant.overviewTab.steps.indicesStep.noRemainingIssuesLabel": "廃止された設定は残っていません。", - "xpack.upgradeAssistant.overviewTab.steps.indicesStep.remainingIssuesDetail": "{numIssues} 件の問題が解決されました。", - "xpack.upgradeAssistant.overviewTab.steps.indicesStep.todo.indicesTabButtonLabel": "インデックスタブ", - "xpack.upgradeAssistant.overviewTab.steps.indicesStep.todo.todoDetail": "{indicesTabButton} に移動して廃止された設定を更新してください。", - "xpack.upgradeAssistant.overviewTab.steps.startUpgradeStep.stepTitle": "アップグレード開始", - "xpack.upgradeAssistant.overviewTab.steps.startUpgradeStepCloud.stepDetail.goToCloudDashboardDetail": "Elastic Cloud ダッシュボードのデプロイセクションに移動し、アップグレードを開始します。", - "xpack.upgradeAssistant.overviewTab.steps.startUpgradeStepOnPrem.stepDetail.followInstructionsDetail": "{instructionButton} に従い、アップグレードを開始します。", - "xpack.upgradeAssistant.overviewTab.steps.startUpgradeStepOnPrem.stepDetail.instructionButtonLabel": "これらの手順", - "xpack.upgradeAssistant.overviewTab.steps.waitForReleaseStep.stepDetail": "リリースされ次第最新の {currentEsMajorVersion} バージョンにアップグレードし、ここに戻って {nextEsMajorVersion} へのアップグレードを行ってください。", - "xpack.upgradeAssistant.overviewTab.steps.waitForReleaseStep.stepTitle": "Elasticsearch {nextEsVersion} のリリース待ち", - "xpack.upgradeAssistant.overviewTab.tabDetail": "このアシスタントは、クラスターとインデックスの Elasticsearch への準備に役立ちます {nextEsVersion} 対処が必要な他の問題に関しては、Elasticsearch のログをご覧ください。", "xpack.upgradeAssistant.reindex.reindexPrivilegesErrorBatch": "「{indexName}」に再インデックスするための権限が不十分です。", - "xpack.upgradeAssistant.tabs.checkupTab.clusterLabel": "クラスター", "xpack.upgradeAssistant.tabs.incompleteCallout.calloutBody.breackingChangesDocButtonLabel": "廃止と互換性を破る変更", "xpack.upgradeAssistant.tabs.incompleteCallout.calloutBody.calloutDetail": "Elasticsearch {nextEsVersion} の {breakingChangesDocButton} の完全なリストは、最終の {currentEsVersion} マイナーリリースで確認できます。この警告は、リストがすべて解決されると消えます。", "xpack.upgradeAssistant.tabs.incompleteCallout.calloutTitle": "リストの問題がすべて解決されていない可能性があります。", - "xpack.upgradeAssistant.tabs.upgradingInterstitial.upgradeCompleteDescription": "すべての Elasticsearch ノードがアップグレードされました。Kibana をアップデートする準備ができました。", - "xpack.upgradeAssistant.tabs.upgradingInterstitial.upgradeCompleteTitle": "クラスターがアップグレードされました", - "xpack.upgradeAssistant.tabs.upgradingInterstitial.upgradingDescription": "1 つまたは複数の Elasticsearch ノードに、 Kibana よりも新しいバージョンの Elasticsearch があります。すべてのノードがアップグレードされた後で Kibana をアップグレードしてください。", - "xpack.upgradeAssistant.tabs.upgradingInterstitial.upgradingTitle": "クラスターをアップグレード中です", "xpack.uptime.addDataButtonLabel": "データの追加", "xpack.uptime.alerts.anomaly.criteriaExpression.ariaLabel": "選択したモニターの条件を表示する式。", "xpack.uptime.alerts.anomaly.criteriaExpression.description": "監視するとき", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 97317818f10cb9..917c68913d4620 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -22950,14 +22950,9 @@ "xpack.uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatErrorMessage": "格式无效:{message}", "xpack.uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatGeneralErrorMessage": "格式无效。例如:{exampleUrl}", "xpack.upgradeAssistant.appTitle": "{version} 升级助手", - "xpack.upgradeAssistant.checkupTab.backUpCallout.calloutBody.calloutDetail": "使用 {snapshotRestoreDocsButton} 备份您的数据。", - "xpack.upgradeAssistant.checkupTab.backUpCallout.calloutBody.snapshotRestoreDocsButtonLabel": "快照和还原 API", - "xpack.upgradeAssistant.checkupTab.backUpCallout.calloutTitle": "立即备份索引", "xpack.upgradeAssistant.checkupTab.changeFiltersShowMoreLabel": "更改筛选以显示更多内容。", - "xpack.upgradeAssistant.checkupTab.clusterTabLabel": "集群", "xpack.upgradeAssistant.checkupTab.controls.collapseAllButtonLabel": "折叠全部", "xpack.upgradeAssistant.checkupTab.controls.expandAllButtonLabel": "展开全部", - "xpack.upgradeAssistant.checkupTab.controls.filterBar.allButtonLabel": "全部", "xpack.upgradeAssistant.checkupTab.controls.filterBar.criticalButtonLabel": "紧急", "xpack.upgradeAssistant.checkupTab.controls.filterErrorMessageLabel": "筛选无效:{searchTermError}", "xpack.upgradeAssistant.checkupTab.controls.groupByBar.byIndexLabel": "按索引", @@ -22972,13 +22967,10 @@ "xpack.upgradeAssistant.checkupTab.deprecations.indexTable.indexColumnLabel": "索引", "xpack.upgradeAssistant.checkupTab.deprecations.warningActionTooltip": "建议在升级之前先解决此问题,但这不是必需的。", "xpack.upgradeAssistant.checkupTab.deprecations.warningLabel": "警告", - "xpack.upgradeAssistant.checkupTab.indexLabel": "索引", "xpack.upgradeAssistant.checkupTab.indicesBadgeLabel": "{numIndices, plural, other { 个索引}}", - "xpack.upgradeAssistant.checkupTab.indicesTabLabel": "索引", "xpack.upgradeAssistant.checkupTab.noDeprecationsLabel": "无弃用内容", "xpack.upgradeAssistant.checkupTab.noIssues.nextStepsDetail": "选中 {overviewTabButton} 以执行后续步骤。", "xpack.upgradeAssistant.checkupTab.noIssues.nextStepsDetail.overviewTabButtonLabel": "“概述”选项卡", - "xpack.upgradeAssistant.checkupTab.noIssues.noIssuesLabel": "您没有 {strongCheckupLabel} 问题。", "xpack.upgradeAssistant.checkupTab.noIssues.noIssuesTitle": "全部清除!", "xpack.upgradeAssistant.checkupTab.numDeprecationsShownLabel": "显示 {numShown} 个,共 {total} 个", "xpack.upgradeAssistant.checkupTab.reindexing.flyout.checklistStep.cancelButtonLabel": "取消", @@ -23021,45 +23013,12 @@ "xpack.upgradeAssistant.checkupTab.reindexing.reindexButton.loadingLabel": "正在加载……", "xpack.upgradeAssistant.checkupTab.reindexing.reindexButton.pausedLabel": "已暂停", "xpack.upgradeAssistant.checkupTab.reindexing.reindexButton.reindexLabel": "重新索引", - "xpack.upgradeAssistant.checkupTab.tabDetail": "您需要注意这些 {strongCheckupLabel} 问题。在升级到 Elasticsearch {nextEsVersion} 之前先解决它们。", - "xpack.upgradeAssistant.forbiddenErrorCallout.calloutTitle": "您没有足够的权限来查看此页。", - "xpack.upgradeAssistant.genericErrorCallout.calloutTitle": "检索检查结果时出错。", - "xpack.upgradeAssistant.overviewTab.overviewTabTitle": "概览", - "xpack.upgradeAssistant.overviewTab.steps.clusterStep.issuesRemainingStepTitle": "检查集群是否存在问题", - "xpack.upgradeAssistant.overviewTab.steps.clusterStep.noIssuesRemainingStepTitle": "您的集群设置已就绪", - "xpack.upgradeAssistant.overviewTab.steps.clusterStep.noRemainingIssuesLabel": "没有其余已弃用设置。", - "xpack.upgradeAssistant.overviewTab.steps.clusterStep.remainingIssuesDetail": "必须解决 {numIssues} 个问题。", - "xpack.upgradeAssistant.overviewTab.steps.clusterStep.todo.clusterTabButtonLabel": "“集群”选项卡", - "xpack.upgradeAssistant.overviewTab.steps.clusterStep.todo.todoDetail": "转到 {clusterTabButton} 并更新已弃用的设置。", - "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.deprecationLogs.deprecationLogsDocButtonLabel": "弃用日志", - "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.deprecationLogs.logsDetail": "请参阅{deprecationLogsDocButton},了解您的应用程序是否使用未在 {nextEsVersion} 中提供的功能。您可能需要启用弃用日志。", - "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingLabel": "是否启用弃用日志?", - "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.disabledLabel": "关闭", "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.enabledLabel": "开启", "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.errorLabel": "无法加载日志状态", - "xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.stepTitle": "查看 Elasticsearch 弃用日志", - "xpack.upgradeAssistant.overviewTab.steps.indicesStep.issuesRemainingStepTitle": "检查索引是否存在问题", - "xpack.upgradeAssistant.overviewTab.steps.indicesStep.noIssuesRemainingStepTitle": "您的索引设置已就绪", - "xpack.upgradeAssistant.overviewTab.steps.indicesStep.noRemainingIssuesLabel": "没有其余已弃用设置。", - "xpack.upgradeAssistant.overviewTab.steps.indicesStep.remainingIssuesDetail": "必须解决 {numIssues} 个问题。", - "xpack.upgradeAssistant.overviewTab.steps.indicesStep.todo.indicesTabButtonLabel": "“索引”选项卡", - "xpack.upgradeAssistant.overviewTab.steps.indicesStep.todo.todoDetail": "转到 {indicesTabButton} 并更新已弃用的设置。", - "xpack.upgradeAssistant.overviewTab.steps.startUpgradeStep.stepTitle": "开始升级", - "xpack.upgradeAssistant.overviewTab.steps.startUpgradeStepCloud.stepDetail.goToCloudDashboardDetail": "转到 Elastic Cloud 仪表板上的“部署”部分开始升级。", - "xpack.upgradeAssistant.overviewTab.steps.startUpgradeStepOnPrem.stepDetail.followInstructionsDetail": "按照 {instructionButton} 开始升级。", - "xpack.upgradeAssistant.overviewTab.steps.startUpgradeStepOnPrem.stepDetail.instructionButtonLabel": "以下说明", - "xpack.upgradeAssistant.overviewTab.steps.waitForReleaseStep.stepDetail": "版本发布后,请升级到最新的 {currentEsMajorVersion} 版本,然后返回此处,继续升级到 {nextEsMajorVersion}。", - "xpack.upgradeAssistant.overviewTab.steps.waitForReleaseStep.stepTitle": "等待 Elasticsearch {nextEsVersion} 发布版", - "xpack.upgradeAssistant.overviewTab.tabDetail": "此助理将帮助您为 Elasticsearch {nextEsVersion} 准备集群和索引。有关需要注意的其他问题,请参阅 Elasticsearch 日志。", "xpack.upgradeAssistant.reindex.reindexPrivilegesErrorBatch": "您没有足够的权限重新索引“{indexName}”。", - "xpack.upgradeAssistant.tabs.checkupTab.clusterLabel": "集群", "xpack.upgradeAssistant.tabs.incompleteCallout.calloutBody.breackingChangesDocButtonLabel": "弃用内容和重大更改", "xpack.upgradeAssistant.tabs.incompleteCallout.calloutBody.calloutDetail": "Elasticsearch {nextEsVersion} 中的 {breakingChangesDocButton} 完整列表将在最终的 {currentEsVersion} 次要版本中提供。完成列表后,此警告将消失。", "xpack.upgradeAssistant.tabs.incompleteCallout.calloutTitle": "问题列表可能不完整", - "xpack.upgradeAssistant.tabs.upgradingInterstitial.upgradeCompleteDescription": "所有 Elasticsearch 节点已升级。可以现在升级 Kibana。", - "xpack.upgradeAssistant.tabs.upgradingInterstitial.upgradeCompleteTitle": "您的集群已升级", - "xpack.upgradeAssistant.tabs.upgradingInterstitial.upgradingDescription": "一个或多个 Elasticsearch 节点的 Elasticsearch 版本比 Kibana 版本新。所有节点升级后,请升级 Kibana。", - "xpack.upgradeAssistant.tabs.upgradingInterstitial.upgradingTitle": "您的集群正在升级", "xpack.uptime.addDataButtonLabel": "添加数据", "xpack.uptime.alerts.anomaly.criteriaExpression.ariaLabel": "显示选定监测的条件的表达式。", "xpack.uptime.alerts.anomaly.criteriaExpression.description": "当监测", diff --git a/x-pack/plugins/upgrade_assistant/kibana.json b/x-pack/plugins/upgrade_assistant/kibana.json index eda624dc422468..d9f4917fa0a6cd 100644 --- a/x-pack/plugins/upgrade_assistant/kibana.json +++ b/x-pack/plugins/upgrade_assistant/kibana.json @@ -6,5 +6,5 @@ "configPath": ["xpack", "upgrade_assistant"], "requiredPlugins": ["management", "licensing", "features"], "optionalPlugins": ["cloud", "usageCollection"], - "requiredBundles": ["esUiShared"] + "requiredBundles": ["esUiShared", "kibanaReact"] } diff --git a/x-pack/plugins/upgrade_assistant/public/application/app.tsx b/x-pack/plugins/upgrade_assistant/public/application/app.tsx index 1276198a528df0..7be723e335e8bf 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/app.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/app.tsx @@ -6,19 +6,48 @@ */ import React from 'react'; -import { I18nStart } from 'src/core/public'; -import { AppContextProvider, ContextValue } from './app_context'; -import { PageContent } from './components/page_content'; +import { Router, Switch, Route, Redirect } from 'react-router-dom'; +import { I18nStart, ScopedHistory } from 'src/core/public'; +import { AppContextProvider, ContextValue, useAppContext } from './app_context'; +import { ComingSoonPrompt } from './components/coming_soon_prompt'; +import { EsDeprecationsContent } from './components/es_deprecations'; +import { DeprecationsOverview } from './components/overview'; export interface AppDependencies extends ContextValue { i18n: I18nStart; + history: ScopedHistory; } -export const RootComponent = ({ i18n, ...contextValue }: AppDependencies) => { +const App: React.FunctionComponent = () => { + const { isReadOnlyMode } = useAppContext(); + + // Read-only mode will be enabled up until the last minor before the next major release + if (isReadOnlyMode) { + return ; + } + + return ( + + + + + + ); +}; + +export const AppWithRouter = ({ history }: { history: ScopedHistory }) => { + return ( + + + + ); +}; + +export const RootComponent = ({ i18n, history, ...contextValue }: AppDependencies) => { return ( - + ); diff --git a/x-pack/plugins/upgrade_assistant/public/application/app_context.tsx b/x-pack/plugins/upgrade_assistant/public/application/app_context.tsx index 2b49d1a5bca1f5..18df47d4cbd4ad 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/app_context.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/app_context.tsx @@ -5,9 +5,10 @@ * 2.0. */ -import { DocLinksStart, HttpSetup, NotificationsStart } from 'src/core/public'; +import { CoreStart, DocLinksStart, HttpSetup, NotificationsStart } from 'src/core/public'; import React, { createContext, useContext } from 'react'; import { ApiService } from './lib/api'; +import { BreadcrumbService } from './lib/breadcrumbs'; export interface KibanaVersionContext { currentMajor: number; @@ -23,6 +24,8 @@ export interface ContextValue { notifications: NotificationsStart; isReadOnlyMode: boolean; api: ApiService; + breadcrumbs: BreadcrumbService; + getUrlForApp: CoreStart['application']['getUrlForApp']; } export const AppContext = createContext({} as any); diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/error_banner.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/error_banner.tsx deleted file mode 100644 index 72e6c5c0702aff..00000000000000 --- a/x-pack/plugins/upgrade_assistant/public/application/components/error_banner.tsx +++ /dev/null @@ -1,49 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { EuiCallOut } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { UpgradeAssistantTabProps } from './types'; - -export const LoadingErrorBanner: React.FunctionComponent< - Pick -> = ({ loadingError }) => { - if (loadingError?.statusCode === 403) { - return ( - - } - color="danger" - iconType="cross" - data-test-subj="permissionsError" - /> - ); - } - - return ( - - } - color="danger" - iconType="cross" - data-test-subj="upgradeStatusError" - > - {loadingError ? loadingError.message : null} - - ); -}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/__snapshots__/filter_bar.test.tsx.snap b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/__snapshots__/filter_bar.test.tsx.snap index da9153f4a6c8d6..b88886b3641659 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/__snapshots__/filter_bar.test.tsx.snap +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/__snapshots__/filter_bar.test.tsx.snap @@ -6,20 +6,22 @@ exports[`FilterBar renders 1`] = ` > - all + critical - critical + warning diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_tab.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_tab.tsx deleted file mode 100644 index a5ae341f1e4248..00000000000000 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_tab.tsx +++ /dev/null @@ -1,222 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { find } from 'lodash'; -import React, { FunctionComponent, useState } from 'react'; - -import { - EuiCallOut, - EuiEmptyPrompt, - EuiLink, - EuiPageContent, - EuiPageContentBody, - EuiSpacer, - EuiText, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { LoadingErrorBanner } from '../error_banner'; -import { useAppContext } from '../../app_context'; -import { GroupByOption, LevelFilterOption, UpgradeAssistantTabProps } from '../types'; -import { CheckupControls } from './controls'; -import { GroupedDeprecations } from './deprecations/grouped'; - -export interface CheckupTabProps extends UpgradeAssistantTabProps { - checkupLabel: string; - showBackupWarning?: boolean; -} - -/** - * Displays a list of deprecations that filterable and groupable. Can be used for cluster, - * nodes, or indices checkups. - */ -export const DeprecationTab: FunctionComponent = ({ - alertBanner, - checkupLabel, - deprecations, - loadingError, - isLoading, - refreshCheckupData, - setSelectedTabIndex, - showBackupWarning = false, -}) => { - const [currentFilter, setCurrentFilter] = useState(LevelFilterOption.all); - const [search, setSearch] = useState(''); - const [currentGroupBy, setCurrentGroupBy] = useState(GroupByOption.message); - - const { docLinks, kibanaVersionInfo } = useAppContext(); - - const { DOC_LINK_VERSION, ELASTIC_WEBSITE_URL } = docLinks; - const esDocBasePath = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}`; - - const { nextMajor } = kibanaVersionInfo; - - const changeFilter = (filter: LevelFilterOption) => { - setCurrentFilter(filter); - }; - - const changeSearch = (newSearch: string) => { - setSearch(newSearch); - }; - - const changeGroupBy = (groupBy: GroupByOption) => { - setCurrentGroupBy(groupBy); - }; - - const availableGroupByOptions = () => { - if (!deprecations) { - return []; - } - - return Object.keys(GroupByOption).filter((opt) => find(deprecations, opt)) as GroupByOption[]; - }; - - const renderCheckupData = () => { - return ( - - ); - }; - - return ( - <> - - -

- {checkupLabel}, - nextEsVersion: `${nextMajor}.x`, - }} - /> -

-
- - - - {alertBanner && ( - <> - {alertBanner} - - - )} - - {showBackupWarning && ( - <> - - } - color="warning" - iconType="help" - > -

- - - - ), - }} - /> -

-
- - - )} - - - - {loadingError ? ( - - ) : deprecations && deprecations.length > 0 ? ( -
- - - {renderCheckupData()} -
- ) : ( - - -
- } - body={ - <> -

- {checkupLabel}, - }} - /> -

-

- setSelectedTabIndex(0)}> - - - ), - }} - /> -

- - } - /> - )} - - - - ); -}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_tab_content.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_tab_content.tsx new file mode 100644 index 00000000000000..9e8678fea0eb90 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_tab_content.tsx @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { find } from 'lodash'; +import React, { FunctionComponent, useState } from 'react'; + +import { EuiEmptyPrompt, EuiLink, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { SectionLoading } from '../../../shared_imports'; +import { GroupByOption, LevelFilterOption, UpgradeAssistantTabProps } from '../types'; +import { CheckupControls } from './controls'; +import { GroupedDeprecations } from './deprecations/grouped'; +import { EsDeprecationErrors } from './es_deprecation_errors'; + +const i18nTexts = { + isLoading: i18n.translate('xpack.upgradeAssistant.esDeprecations.loadingText', { + defaultMessage: 'Loading deprecations…', + }), +}; + +export interface CheckupTabProps extends UpgradeAssistantTabProps { + checkupLabel: string; +} + +/** + * Displays a list of deprecations that are filterable and groupable. Can be used for cluster, + * nodes, or indices deprecations. + */ +export const DeprecationTabContent: FunctionComponent = ({ + checkupLabel, + deprecations, + error, + isLoading, + refreshCheckupData, + navigateToOverviewPage, +}) => { + const [currentFilter, setCurrentFilter] = useState(LevelFilterOption.all); + const [search, setSearch] = useState(''); + const [currentGroupBy, setCurrentGroupBy] = useState(GroupByOption.message); + + const availableGroupByOptions = () => { + if (!deprecations) { + return []; + } + + return Object.keys(GroupByOption).filter((opt) => find(deprecations, opt)) as GroupByOption[]; + }; + + if (deprecations && deprecations.length === 0) { + return ( + + +
+ } + body={ + <> +

+ +

+

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

+ + } + /> + ); + } + + let content: React.ReactNode; + + if (isLoading) { + content = {i18nTexts.isLoading}; + } else if (deprecations?.length) { + content = ( +
+ + + + + +
+ ); + } else if (error) { + content = ; + } + + return ( +
+ + + {content} +
+ ); +}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/es_deprecation_errors.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/es_deprecation_errors.tsx new file mode 100644 index 00000000000000..239433808c5aff --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/es_deprecation_errors.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiCallOut } from '@elastic/eui'; + +import { ResponseError } from '../../lib/api'; +import { getEsDeprecationError } from '../../lib/es_deprecation_errors'; +interface Props { + error: ResponseError; +} + +export const EsDeprecationErrors: React.FunctionComponent = ({ error }) => { + const { code: errorType, message } = getEsDeprecationError(error); + + switch (errorType) { + case 'unauthorized_error': + return ( + + ); + case 'partially_upgraded_error': + return ( + + ); + case 'upgraded_error': + return ; + case 'request_error': + default: + return ( + + {error.message} + + ); + } +}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/es_deprecations.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/es_deprecations.tsx new file mode 100644 index 00000000000000..0da4a4877a7ec5 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/es_deprecations.tsx @@ -0,0 +1,212 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo, useEffect, useState } from 'react'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; + +import { + EuiButton, + EuiButtonEmpty, + EuiPageBody, + EuiPageHeader, + EuiTabbedContent, + EuiTabbedContentTab, + EuiPageContent, + EuiPageContentBody, + EuiToolTip, + EuiNotificationBadge, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { useAppContext } from '../../app_context'; +import { UpgradeAssistantTabProps, EsTabs, TelemetryState } from '../types'; +import { DeprecationTabContent } from './deprecation_tab_content'; + +const i18nTexts = { + pageTitle: i18n.translate('xpack.upgradeAssistant.esDeprecations.pageTitle', { + defaultMessage: 'Elasticsearch', + }), + pageDescription: i18n.translate('xpack.upgradeAssistant.esDeprecations.pageDescription', { + defaultMessage: + 'Review the deprecated cluster and index settings. You must resolve any critical issues before upgrading.', + }), + docLinkText: i18n.translate('xpack.upgradeAssistant.esDeprecations.docLinkText', { + defaultMessage: 'Documentation', + }), + backupDataButton: { + label: i18n.translate('xpack.upgradeAssistant.esDeprecations.backupDataButtonLabel', { + defaultMessage: 'Back up your data', + }), + tooltipText: i18n.translate('xpack.upgradeAssistant.esDeprecations.backupDataTooltipText', { + defaultMessage: 'Take a snapshot before you make any changes.', + }), + }, + clusterTab: { + tabName: i18n.translate('xpack.upgradeAssistant.esDeprecations.clusterTabLabel', { + defaultMessage: 'Cluster', + }), + deprecationType: i18n.translate('xpack.upgradeAssistant.esDeprecations.clusterLabel', { + defaultMessage: 'cluster', + }), + }, + indicesTab: { + tabName: i18n.translate('xpack.upgradeAssistant.esDeprecations.indicesTabLabel', { + defaultMessage: 'Indices', + }), + deprecationType: i18n.translate('xpack.upgradeAssistant.esDeprecations.indexLabel', { + defaultMessage: 'index', + }), + }, +}; + +interface MatchParams { + tabName: EsTabs; +} + +export const EsDeprecationsContent = withRouter( + ({ + match: { + params: { tabName }, + }, + history, + }: RouteComponentProps) => { + const [telemetryState, setTelemetryState] = useState(TelemetryState.Complete); + + const { api, breadcrumbs, getUrlForApp, docLinks } = useAppContext(); + + const { data: checkupData, isLoading, error, resendRequest } = api.useLoadUpgradeStatus(); + + const onTabClick = (selectedTab: EuiTabbedContentTab) => { + history.push(`/es_deprecations/${selectedTab.id}`); + }; + + const tabs = useMemo(() => { + const commonTabProps: UpgradeAssistantTabProps = { + error, + isLoading, + refreshCheckupData: resendRequest, + navigateToOverviewPage: () => history.push('/overview'), + }; + + return [ + { + id: 'cluster', + 'data-test-subj': 'upgradeAssistantClusterTab', + name: ( + + {i18nTexts.clusterTab.tabName} + {checkupData && checkupData.cluster.length > 0 && ( + <> + {' '} + {checkupData.cluster.length} + + )} + + ), + content: ( + + ), + }, + { + id: 'indices', + 'data-test-subj': 'upgradeAssistantIndicesTab', + name: ( + + {i18nTexts.indicesTab.tabName} + {checkupData && checkupData.indices.length > 0 && ( + <> + {' '} + {checkupData.indices.length} + + )} + + ), + content: ( + + ), + }, + ]; + }, [checkupData, error, history, isLoading, resendRequest]); + + useEffect(() => { + breadcrumbs.setBreadcrumbs('esDeprecations'); + }, [breadcrumbs]); + + useEffect(() => { + if (isLoading === false) { + setTelemetryState(TelemetryState.Running); + + async function sendTelemetryData() { + await api.sendTelemetryData({ + [tabName]: true, + }); + setTelemetryState(TelemetryState.Complete); + } + + sendTelemetryData(); + } + }, [api, tabName, isLoading]); + + return ( + + + + {i18nTexts.docLinkText} + , + ]} + > + + + {i18nTexts.backupDataButton.label} + + + + + + tab.id === tabName)} + /> + + + + ); + } +); diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/filter_bar.test.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/filter_bar.test.tsx index feac88cf4a5251..4888efda97bd05 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/filter_bar.test.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/filter_bar.test.tsx @@ -17,7 +17,7 @@ const defaultProps = { { level: LevelFilterOption.critical }, { level: LevelFilterOption.critical }, ] as DeprecationInfo[], - currentFilter: LevelFilterOption.critical, + currentFilter: LevelFilterOption.all, onFilterChange: jest.fn(), }; @@ -28,7 +28,7 @@ describe('FilterBar', () => { test('clicking button calls onFilterChange', () => { const wrapper = mount(); - wrapper.find('button.euiFilterButton-hasActiveFilters').simulate('click'); + wrapper.find('button[data-test-subj="criticalLevelFilter"]').simulate('click'); expect(defaultProps.onFilterChange).toHaveBeenCalledTimes(1); expect(defaultProps.onFilterChange.mock.calls[0][0]).toEqual(LevelFilterOption.critical); }); diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/filter_bar.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/filter_bar.tsx index 7ef3ae2fc93325..848ac3b14a817f 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/filter_bar.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/filter_bar.tsx @@ -15,17 +15,18 @@ import { DeprecationInfo } from '../../../../common/types'; import { LevelFilterOption } from '../types'; const LocalizedOptions: { [option: string]: string } = { - all: i18n.translate('xpack.upgradeAssistant.checkupTab.controls.filterBar.allButtonLabel', { - defaultMessage: 'all', - }), + warning: i18n.translate( + 'xpack.upgradeAssistant.checkupTab.controls.filterBar.warningButtonLabel', + { + defaultMessage: 'warning', + } + ), critical: i18n.translate( 'xpack.upgradeAssistant.checkupTab.controls.filterBar.criticalButtonLabel', { defaultMessage: 'critical' } ), }; -const allFilterOptions = Object.keys(LevelFilterOption) as LevelFilterOption[]; - interface FilterBarProps { allDeprecations?: DeprecationInfo[]; currentFilter: LevelFilterOption; @@ -43,23 +44,40 @@ export const FilterBar: React.FunctionComponent = ({ return counts; }, {} as { [level: string]: number }); - const allCount = allDeprecations.length; - return ( - {allFilterOptions.map((option) => ( - - {LocalizedOptions[option]} - - ))} + { + onFilterChange( + currentFilter !== LevelFilterOption.critical + ? LevelFilterOption.critical + : LevelFilterOption.all + ); + }} + hasActiveFilters={currentFilter === LevelFilterOption.critical} + numFilters={levelCounts[LevelFilterOption.critical] || undefined} + data-test-subj="criticalLevelFilter" + > + {LocalizedOptions[LevelFilterOption.critical]} + + { + onFilterChange( + currentFilter !== LevelFilterOption.warning + ? LevelFilterOption.warning + : LevelFilterOption.all + ); + }} + hasActiveFilters={currentFilter === LevelFilterOption.warning} + numFilters={levelCounts[LevelFilterOption.warning] || undefined} + data-test-subj="warningLevelFilter" + > + {LocalizedOptions[LevelFilterOption.warning]} + ); diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/index.ts b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/index.ts index 8b7435b94b2c1a..0e69259adc6096 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/index.ts +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { DeprecationTab } from './deprecation_tab'; +export { EsDeprecationsContent } from './es_deprecations'; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/overview/deprecation_logging_toggle.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/overview/deprecation_logging_toggle.tsx index 5ed46c25ecf17c..6be7793f0bd4a4 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/overview/deprecation_logging_toggle.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/overview/deprecation_logging_toggle.tsx @@ -13,8 +13,35 @@ import { i18n } from '@kbn/i18n'; import { useAppContext } from '../../app_context'; import { ResponseError } from '../../lib/api'; +const i18nTexts = { + toggleErrorLabel: i18n.translate( + 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.errorLabel', + { + defaultMessage: 'Could not load logging state', + } + ), + toggleLabel: i18n.translate( + 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.enabledLabel', + { + defaultMessage: 'Enable deprecation logging', + } + ), + enabledMessage: i18n.translate( + 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.enabledToastMessage', + { + defaultMessage: 'Log deprecated actions.', + } + ), + disabledMessage: i18n.translate( + 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.disabledToastMessage', + { + defaultMessage: 'Do not log deprecated actions.', + } + ), +}; + export const DeprecationLoggingToggle: React.FunctionComponent = () => { - const { api } = useAppContext(); + const { api, notifications } = useAppContext(); const [isEnabled, setIsEnabled] = useState(true); const [isLoading, setIsLoading] = useState(false); @@ -44,27 +71,10 @@ export const DeprecationLoggingToggle: React.FunctionComponent = () => { const renderLoggingState = () => { if (error) { - return i18n.translate( - 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.errorLabel', - { - defaultMessage: 'Could not load logging state', - } - ); - } else if (isEnabled) { - return i18n.translate( - 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.enabledLabel', - { - defaultMessage: 'On', - } - ); - } else { - return i18n.translate( - 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.disabledLabel', - { - defaultMessage: 'Off', - } - ); + return i18nTexts.toggleErrorLabel; } + + return i18nTexts.toggleLabel; }; const toggleLogging = async () => { @@ -82,6 +92,9 @@ export const DeprecationLoggingToggle: React.FunctionComponent = () => { setError(updateError); } else if (data) { setIsEnabled(data.isEnabled); + notifications.toasts.addSuccess( + data.isEnabled ? i18nTexts.enabledMessage : i18nTexts.disabledMessage + ); } }; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/overview/es_stats.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/overview/es_stats.tsx new file mode 100644 index 00000000000000..51a66bdd353956 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/application/components/overview/es_stats.tsx @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FunctionComponent } from 'react'; + +import { + EuiLink, + EuiPanel, + EuiStat, + EuiTitle, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { RouteComponentProps } from 'react-router-dom'; +import { reactRouterNavigate } from '../../../../../../../src/plugins/kibana_react/public'; +import { useAppContext } from '../../app_context'; +import { EsStatsErrors } from './es_stats_error'; + +const i18nTexts = { + statsTitle: i18n.translate('xpack.upgradeAssistant.esDeprecationStats.statsTitle', { + defaultMessage: 'Elasticsearch', + }), + totalDeprecationsTitle: i18n.translate( + 'xpack.upgradeAssistant.esDeprecationStats.totalDeprecationsTitle', + { + defaultMessage: 'Deprecations', + } + ), + criticalDeprecationsTitle: i18n.translate( + 'xpack.upgradeAssistant.esDeprecationStats.criticalDeprecationsTitle', + { + defaultMessage: 'Critical', + } + ), + viewDeprecationsLink: i18n.translate( + 'xpack.upgradeAssistant.esDeprecationStats.viewDeprecationsLinkText', + { + defaultMessage: 'View deprecations', + } + ), + getTotalDeprecationsTooltip: (clusterCount: number, indexCount: number) => + i18n.translate('xpack.upgradeAssistant.esDeprecationStats.totalDeprecationsTooltip', { + defaultMessage: + 'This cluster is using {clusterCount} deprecated cluster settings and {indexCount} deprecated index settings', + values: { + clusterCount, + indexCount, + }, + }), +}; + +interface Props { + history: RouteComponentProps['history']; +} + +export const ESDeprecationStats: FunctionComponent = ({ history }) => { + const { api } = useAppContext(); + + const { data: esDeprecations, isLoading, error } = api.useLoadUpgradeStatus(); + + const allDeprecations = esDeprecations?.cluster?.concat(esDeprecations?.indices) ?? []; + const criticalDeprecations = allDeprecations.filter( + (deprecation) => deprecation.level === 'critical' + ); + + return ( + + + + +

{i18nTexts.statsTitle}

+
+
+ + + {i18nTexts.viewDeprecationsLink} + + +
+ + + + + + + {i18nTexts.totalDeprecationsTitle}{' '} + + + } + isLoading={isLoading} + /> + + + + + {error && } + + + +
+ ); +}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/overview/es_stats_error.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/overview/es_stats_error.tsx new file mode 100644 index 00000000000000..dda7d16599e0c2 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/application/components/overview/es_stats_error.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiIconTip, EuiSpacer } from '@elastic/eui'; +import { ResponseError } from '../../lib/api'; +import { getEsDeprecationError } from '../../lib/es_deprecation_errors'; + +interface Props { + error: ResponseError; +} + +export const EsStatsErrors: React.FunctionComponent = ({ error }) => { + let iconContent: React.ReactNode; + + const { code: errorType, message } = getEsDeprecationError(error); + + switch (errorType) { + case 'unauthorized_error': + iconContent = ( + + ); + break; + case 'partially_upgraded_error': + iconContent = ( + + ); + break; + case 'upgraded_error': + iconContent = ( + + ); + break; + case 'request_error': + default: + iconContent = ( + + ); + } + + return ( + <> + + {iconContent} + + ); +}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/overview/index.ts b/x-pack/plugins/upgrade_assistant/public/application/components/overview/index.ts index c43c1415f6f7c2..a64d7b0d449158 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/overview/index.ts +++ b/x-pack/plugins/upgrade_assistant/public/application/components/overview/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { OverviewTab } from './overview'; +export { DeprecationsOverview } from './overview'; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/overview/overview.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/overview/overview.tsx index 01677e7394a872..0784fbc102805c 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/overview/overview.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/overview/overview.tsx @@ -5,70 +5,133 @@ * 2.0. */ -import React, { FunctionComponent } from 'react'; +import React, { FunctionComponent, useEffect } from 'react'; import { - EuiFlexGroup, - EuiFlexItem, - EuiLoadingSpinner, EuiPageContent, EuiPageContentBody, - EuiSpacer, EuiText, + EuiPageHeader, + EuiPageBody, + EuiButtonEmpty, + EuiFlexItem, + EuiFlexGroup, + EuiSpacer, + EuiLink, + EuiFormRow, } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { RouteComponentProps } from 'react-router-dom'; import { useAppContext } from '../../app_context'; -import { LoadingErrorBanner } from '../error_banner'; -import { UpgradeAssistantTabProps } from '../types'; -import { Steps } from './steps'; +import { LatestMinorBanner } from '../latest_minor_banner'; +import { ESDeprecationStats } from './es_stats'; +import { DeprecationLoggingToggle } from './deprecation_logging_toggle'; -export const OverviewTab: FunctionComponent = (props) => { - const { kibanaVersionInfo } = useAppContext(); +const i18nTexts = { + pageTitle: i18n.translate('xpack.upgradeAssistant.pageTitle', { + defaultMessage: 'Upgrade Assistant', + }), + getPageDescription: (nextMajor: string) => + i18n.translate('xpack.upgradeAssistant.pageDescription', { + defaultMessage: + 'Prepare to upgrade by identifying deprecated settings and updating your configuration. Enable deprecation logging to see if your are using deprecated features that will not be available after you upgrade to Elastic {nextMajor}.', + values: { + nextMajor, + }, + }), + getDeprecationLoggingLabel: (href: string) => ( + + {i18n.translate('xpack.upgradeAssistant.deprecationLoggingDescription.learnMoreLink', { + defaultMessage: 'Learn more.', + })} + + ), + }} + /> + ), + docLink: i18n.translate('xpack.upgradeAssistant.documentationLinkText', { + defaultMessage: 'Documentation', + }), +}; + +interface Props { + history: RouteComponentProps['history']; +} + +export const DeprecationsOverview: FunctionComponent = ({ history }) => { + const { kibanaVersionInfo, breadcrumbs, docLinks, api } = useAppContext(); const { nextMajor } = kibanaVersionInfo; + useEffect(() => { + async function sendTelemetryData() { + await api.sendTelemetryData({ + overview: true, + }); + } + + sendTelemetryData(); + }, [api]); + + useEffect(() => { + breadcrumbs.setBreadcrumbs('overview'); + }, [breadcrumbs]); + return ( - <> - - - -

- -

-
- - - - {props.alertBanner && ( - <> - {props.alertBanner} - - - - )} - - + + + + {i18nTexts.docLink} + , + ]} + /> + - {props.isLoading && ( - - - - - - )} + <> + +

{i18nTexts.getPageDescription(`${nextMajor}.x`)}

+
+ + - {props.checkupData && } + {/* Remove this in last minor of the current major (e.g., 7.15) */} + - {props.loadingError && } + + + + + + + + + + + + + +
- +
); }; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/overview/steps.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/overview/steps.tsx deleted file mode 100644 index 095960ae93562d..00000000000000 --- a/x-pack/plugins/upgrade_assistant/public/application/components/overview/steps.tsx +++ /dev/null @@ -1,293 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { Fragment, FunctionComponent } from 'react'; - -import { - EuiFormRow, - EuiLink, - EuiNotificationBadge, - EuiSpacer, - // @ts-ignore - EuiStat, - EuiSteps, - EuiText, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { useAppContext } from '../../app_context'; -import { UpgradeAssistantTabProps } from '../types'; -import { DeprecationLoggingToggle } from './deprecation_logging_toggle'; - -// Leaving these here even if unused so they are picked up for i18n static analysis -// Keep this until last minor release (when next major is also released). -const WAIT_FOR_RELEASE_STEP = (majorVersion: number, nextMajorVersion: number) => ({ - title: i18n.translate('xpack.upgradeAssistant.overviewTab.steps.waitForReleaseStep.stepTitle', { - defaultMessage: 'Wait for the Elasticsearch {nextEsVersion} release', - values: { - nextEsVersion: `${nextMajorVersion}.0`, - }, - }), - 'data-test-subj': 'waitForReleaseStep', - children: ( - <> - -

- -

-
- - ), -}); - -// Swap in this step for the one above it on the last minor release. -// @ts-ignore -const START_UPGRADE_STEP = (isCloudEnabled: boolean, esDocBasePath: string) => ({ - title: i18n.translate('xpack.upgradeAssistant.overviewTab.steps.startUpgradeStep.stepTitle', { - defaultMessage: 'Start your upgrade', - }), - 'data-test-subj': 'startUpgradeStep', - children: ( - - -

- {isCloudEnabled ? ( - - ) : ( - - - - ), - }} - /> - )} -

-
-
- ), -}); - -export const Steps: FunctionComponent = ({ - checkupData, - setSelectedTabIndex, -}) => { - const checkupDataTyped = (checkupData! as unknown) as { [checkupType: string]: any[] }; - const countByType = Object.keys(checkupDataTyped).reduce((counts, checkupType) => { - counts[checkupType] = checkupDataTyped[checkupType].length; - return counts; - }, {} as { [checkupType: string]: number }); - - // Uncomment when START_UPGRADE_STEP is in use! - const { kibanaVersionInfo, docLinks /* , isCloudEnabled */ } = useAppContext(); - - const { DOC_LINK_VERSION, ELASTIC_WEBSITE_URL } = docLinks; - const esDocBasePath = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}`; - - const { currentMajor, nextMajor } = kibanaVersionInfo; - - return ( - - {countByType.cluster ? ( - -

- setSelectedTabIndex(1)}> - - - ), - }} - /> -

-

- {countByType.cluster} - ), - }} - /> -

-
- ) : ( -

- -

- )} -
- ), - }, - { - title: countByType.indices - ? i18n.translate( - 'xpack.upgradeAssistant.overviewTab.steps.indicesStep.issuesRemainingStepTitle', - { - defaultMessage: 'Check for issues with your indices', - } - ) - : i18n.translate( - 'xpack.upgradeAssistant.overviewTab.steps.indicesStep.noIssuesRemainingStepTitle', - { - defaultMessage: 'Your index settings are ready', - } - ), - status: countByType.indices ? 'warning' : 'complete', - 'data-test-subj': 'indicesIssuesStep', - children: ( - - {countByType.indices ? ( - -

- setSelectedTabIndex(2)}> - - - ), - }} - /> -

-

- {countByType.indices} - ), - }} - /> -

-
- ) : ( -

- -

- )} -
- ), - }, - { - title: i18n.translate( - 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.stepTitle', - { - defaultMessage: 'Review the Elasticsearch deprecation logs', - } - ), - 'data-test-subj': 'deprecationLoggingStep', - children: ( - - -

- - - - ), - nextEsVersion: `${nextMajor}.0`, - }} - /> -

-
- - - - - - -
- ), - }, - - // Swap in START_UPGRADE_STEP on the last minor release. - WAIT_FOR_RELEASE_STEP(currentMajor, nextMajor), - // START_UPGRADE_STEP(isCloudEnabled, esDocBasePath), - ]} - /> - ); -}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/page_content.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/page_content.tsx deleted file mode 100644 index db515f0c123a85..00000000000000 --- a/x-pack/plugins/upgrade_assistant/public/application/components/page_content.tsx +++ /dev/null @@ -1,44 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiPageHeader, EuiPageHeaderSection, EuiTitle } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { useAppContext } from '../app_context'; -import { ComingSoonPrompt } from './coming_soon_prompt'; -import { UpgradeAssistantTabs } from './tabs'; - -export const PageContent: React.FunctionComponent = () => { - const { kibanaVersionInfo, isReadOnlyMode } = useAppContext(); - const { nextMajor } = kibanaVersionInfo; - - // Read-only mode will be enabled up until the last minor before the next major release - if (isReadOnlyMode) { - return ; - } - - return ( - <> - - - -

- -

-
-
-
- - - - ); -}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs.tsx deleted file mode 100644 index 231d9705bd0d91..00000000000000 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs.tsx +++ /dev/null @@ -1,184 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { findIndex } from 'lodash'; -import React, { useEffect, useState, useMemo } from 'react'; - -import { - EuiEmptyPrompt, - EuiPageContent, - EuiPageContentBody, - EuiTabbedContent, - EuiTabbedContentTab, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { LatestMinorBanner } from './latest_minor_banner'; -import { DeprecationTab } from './es_deprecations'; -import { OverviewTab } from './overview'; -import { TelemetryState, UpgradeAssistantTabProps } from './types'; -import { useAppContext } from '../app_context'; - -export const UpgradeAssistantTabs: React.FunctionComponent = () => { - const [selectedTabIndex, setSelectedTabIndex] = useState(0); - const [telemetryState, setTelemetryState] = useState(TelemetryState.Complete); - - const { api } = useAppContext(); - - const { data: checkupData, isLoading, error, resendRequest } = api.useLoadUpgradeStatus(); - - const tabs = useMemo(() => { - const commonTabProps: UpgradeAssistantTabProps = { - loadingError: error, - isLoading, - refreshCheckupData: resendRequest, - setSelectedTabIndex, - // Remove this in last minor of the current major (e.g., 7.15) - alertBanner: , - }; - - return [ - { - id: 'overview', - 'data-test-subj': 'upgradeAssistantOverviewTab', - name: i18n.translate('xpack.upgradeAssistant.overviewTab.overviewTabTitle', { - defaultMessage: 'Overview', - }), - content: , - }, - { - id: 'cluster', - 'data-test-subj': 'upgradeAssistantClusterTab', - name: i18n.translate('xpack.upgradeAssistant.checkupTab.clusterTabLabel', { - defaultMessage: 'Cluster', - }), - content: ( - - ), - }, - { - id: 'indices', - 'data-test-subj': 'upgradeAssistantIndicesTab', - name: i18n.translate('xpack.upgradeAssistant.checkupTab.indicesTabLabel', { - defaultMessage: 'Indices', - }), - content: ( - - ), - }, - ]; - }, [checkupData, error, isLoading, resendRequest]); - - const tabName = tabs[selectedTabIndex].id; - - useEffect(() => { - if (isLoading === false) { - setTelemetryState(TelemetryState.Running); - - async function sendTelemetryData() { - await api.sendTelemetryData({ - [tabName]: true, - }); - setTelemetryState(TelemetryState.Complete); - } - - sendTelemetryData(); - } - }, [api, selectedTabIndex, tabName, isLoading]); - - const onTabClick = (selectedTab: EuiTabbedContentTab) => { - const newSelectedTabIndex = findIndex(tabs, { id: selectedTab.id }); - if (selectedTabIndex === -1) { - throw new Error('Clicked tab did not exist in tabs array'); - } - setSelectedTabIndex(newSelectedTabIndex); - }; - - if (error?.statusCode === 426 && error.attributes?.allNodesUpgraded === false) { - return ( - - - - -
- } - body={ -

- -

- } - /> - - - ); - } else if (error?.statusCode === 426 && error.attributes?.allNodesUpgraded === true) { - return ( - - - - - - } - body={ -

- -

- } - /> -
-
- ); - } - - return ( - - ); -}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/types.ts b/x-pack/plugins/upgrade_assistant/public/application/components/types.ts index 8be2fe3e0b0aba..d82b779110a89e 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/types.ts +++ b/x-pack/plugins/upgrade_assistant/public/application/components/types.ts @@ -15,9 +15,9 @@ export interface UpgradeAssistantTabProps { checkupData?: UpgradeAssistantStatus | null; deprecations?: EnrichedDeprecationInfo[]; refreshCheckupData: () => void; - loadingError: ResponseError | null; + error: ResponseError | null; isLoading: boolean; - setSelectedTabIndex: (tabIndex: number) => void; + navigateToOverviewPage: () => void; } // eslint-disable-next-line react/prefer-stateless-function @@ -35,6 +35,7 @@ export enum LoadingState { export enum LevelFilterOption { all = 'all', critical = 'critical', + warning = 'warning', } export enum GroupByOption { @@ -47,3 +48,5 @@ export enum TelemetryState { Running, Complete, } + +export type EsTabs = 'cluster' | 'indices'; diff --git a/x-pack/plugins/upgrade_assistant/public/application/lib/breadcrumbs.ts b/x-pack/plugins/upgrade_assistant/public/application/lib/breadcrumbs.ts new file mode 100644 index 00000000000000..3f2ee4fa33657a --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/application/lib/breadcrumbs.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { ManagementAppMountParams } from '../../../../../../src/plugins/management/public'; + +type SetBreadcrumbs = ManagementAppMountParams['setBreadcrumbs']; + +const i18nTexts = { + breadcrumbs: { + overview: i18n.translate('xpack.upgradeAssistant.breadcrumb.overviewLabel', { + defaultMessage: 'Upgrade Assistant', + }), + esDeprecations: i18n.translate('xpack.upgradeAssistant.breadcrumb.esDeprecationsLabel', { + defaultMessage: 'Elasticsearch deprecations', + }), + }, +}; + +export class BreadcrumbService { + private breadcrumbs: { + [key: string]: Array<{ + text: string; + href?: string; + }>; + } = { + overview: [ + { + text: i18nTexts.breadcrumbs.overview, + }, + ], + esDeprecations: [ + { + text: i18nTexts.breadcrumbs.overview, + href: '/', + }, + { + text: i18nTexts.breadcrumbs.esDeprecations, + }, + ], + }; + + private setBreadcrumbsHandler?: SetBreadcrumbs; + + public setup(setBreadcrumbsHandler: SetBreadcrumbs): void { + this.setBreadcrumbsHandler = setBreadcrumbsHandler; + } + + public setBreadcrumbs(type: 'overview' | 'esDeprecations'): void { + if (!this.setBreadcrumbsHandler) { + throw new Error('Breadcrumb service has not been initialized'); + } + + const newBreadcrumbs = this.breadcrumbs[type] + ? [...this.breadcrumbs[type]] + : [...this.breadcrumbs.home]; + + this.setBreadcrumbsHandler(newBreadcrumbs); + } +} + +export const breadcrumbService = new BreadcrumbService(); diff --git a/x-pack/plugins/upgrade_assistant/public/application/lib/es_deprecation_errors.ts b/x-pack/plugins/upgrade_assistant/public/application/lib/es_deprecation_errors.ts new file mode 100644 index 00000000000000..4220f0eef8d42b --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/application/lib/es_deprecation_errors.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { ResponseError } from './api'; + +const i18nTexts = { + permissionsError: i18n.translate( + 'xpack.upgradeAssistant.esDeprecationErrors.permissionsErrorMessage', + { + defaultMessage: 'You are not authorized to view Elasticsearch deprecations.', + } + ), + partiallyUpgradedWarning: i18n.translate( + 'xpack.upgradeAssistant.esDeprecationErrors.partiallyUpgradedWarningMessage', + { + defaultMessage: + 'Upgrade Kibana to the same version as your Elasticsearch cluster. One or more nodes in the cluster is running a different version than Kibana.', + } + ), + upgradedMessage: i18n.translate( + 'xpack.upgradeAssistant.esDeprecationErrors.upgradedWarningMessage', + { + defaultMessage: + 'Your configuration is up to date. Kibana and all Elasticsearch nodes are running the same version.', + } + ), + loadingError: i18n.translate('xpack.upgradeAssistant.esDeprecationErrors.loadingErrorMessage', { + defaultMessage: 'Could not retrieve Elasticsearch deprecations.', + }), +}; + +export const getEsDeprecationError = (error: ResponseError) => { + if (error.statusCode === 403) { + return { + code: 'unauthorized_error', + message: i18nTexts.permissionsError, + }; + } else if (error?.statusCode === 426 && error.attributes?.allNodesUpgraded === false) { + return { + code: 'partially_upgraded_error', + message: i18nTexts.partiallyUpgradedWarning, + }; + } else if (error?.statusCode === 426 && error.attributes?.allNodesUpgraded === true) { + return { + code: 'upgraded_error', + message: i18nTexts.upgradedMessage, + }; + } else { + return { + code: 'request_error', + message: i18nTexts.loadingError, + }; + } +}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/mount_management_section.ts b/x-pack/plugins/upgrade_assistant/public/application/mount_management_section.ts index 681beefdfd00cd..575c85bb33ec01 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/mount_management_section.ts +++ b/x-pack/plugins/upgrade_assistant/public/application/mount_management_section.ts @@ -11,6 +11,7 @@ import { UA_READONLY_MODE } from '../../common/constants'; import { renderApp } from './render_app'; import { KibanaVersionContext } from './app_context'; import { apiService } from './lib/api'; +import { breadcrumbService } from './lib/breadcrumbs'; export async function mountManagementSection( coreSetup: CoreSetup, @@ -18,13 +19,15 @@ export async function mountManagementSection( params: ManagementAppMountParams, kibanaVersionInfo: KibanaVersionContext ) { - const [{ i18n, docLinks, notifications }] = await coreSetup.getStartServices(); + const [{ i18n, docLinks, notifications, application }] = await coreSetup.getStartServices(); + const { element, history, setBreadcrumbs } = params; const { http } = coreSetup; apiService.setup(http); + breadcrumbService.setup(setBreadcrumbs); return renderApp({ - element: params.element, + element, isCloudEnabled, http, i18n, @@ -32,6 +35,9 @@ export async function mountManagementSection( kibanaVersionInfo, notifications, isReadOnlyMode: UA_READONLY_MODE, + history, api: apiService, + breadcrumbs: breadcrumbService, + getUrlForApp: application.getUrlForApp, }); } diff --git a/x-pack/plugins/upgrade_assistant/public/application/render_app.tsx b/x-pack/plugins/upgrade_assistant/public/application/render_app.tsx index a393ae433c5af4..248e6961a74e57 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/render_app.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/render_app.tsx @@ -8,11 +8,9 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { AppDependencies, RootComponent } from './app'; -import { ApiService } from './lib/api'; interface BootDependencies extends AppDependencies { element: HTMLElement; - api: ApiService; } export const renderApp = (deps: BootDependencies) => { diff --git a/x-pack/plugins/upgrade_assistant/public/shared_imports.ts b/x-pack/plugins/upgrade_assistant/public/shared_imports.ts index 6d3984fac68a6c..9007fdc5db04d1 100644 --- a/x-pack/plugins/upgrade_assistant/public/shared_imports.ts +++ b/x-pack/plugins/upgrade_assistant/public/shared_imports.ts @@ -11,4 +11,5 @@ export { SendRequestResponse, useRequest, UseRequestConfig, + SectionLoading, } from '../../../../src/plugins/es_ui_shared/public/'; diff --git a/x-pack/plugins/upgrade_assistant/tests_client_integration/helpers/indices.helpers.ts b/x-pack/plugins/upgrade_assistant/tests_client_integration/helpers/indices.helpers.ts index 5ab5c88cce4bcc..a59aa009a912ba 100644 --- a/x-pack/plugins/upgrade_assistant/tests_client_integration/helpers/indices.helpers.ts +++ b/x-pack/plugins/upgrade_assistant/tests_client_integration/helpers/indices.helpers.ts @@ -6,10 +6,14 @@ */ import { registerTestBed, TestBed, TestBedConfig } from '@kbn/test/jest'; -import { PageContent } from '../../public/application/components/page_content'; +import { EsDeprecationsContent } from '../../public/application/components/es_deprecations'; import { WithAppDependencies } from './setup_environment'; const testBedConfig: TestBedConfig = { + memoryRouter: { + initialEntries: ['/es_deprecations/indices'], + componentRoutePath: '/es_deprecations/:tabName', + }, doMountAsync: true, }; @@ -46,7 +50,10 @@ const createActions = (testBed: TestBed) => { }; export const setup = async (overrides?: Record): Promise => { - const initTestBed = registerTestBed(WithAppDependencies(PageContent, overrides), testBedConfig); + const initTestBed = registerTestBed( + WithAppDependencies(EsDeprecationsContent, overrides), + testBedConfig + ); const testBed = await initTestBed(); return { @@ -60,6 +67,9 @@ export type IndicesTestSubjects = | 'removeIndexSettingsButton' | 'deprecationsContainer' | 'permissionsError' - | 'upgradeStatusError' + | 'requestError' + | 'indexCount' + | 'upgradedCallout' + | 'partiallyUpgradedWarning' | 'noDeprecationsPrompt' | string; diff --git a/x-pack/plugins/upgrade_assistant/tests_client_integration/helpers/overview.helpers.ts b/x-pack/plugins/upgrade_assistant/tests_client_integration/helpers/overview.helpers.ts index 22d00290842f4f..161364f6d45ce7 100644 --- a/x-pack/plugins/upgrade_assistant/tests_client_integration/helpers/overview.helpers.ts +++ b/x-pack/plugins/upgrade_assistant/tests_client_integration/helpers/overview.helpers.ts @@ -6,27 +6,40 @@ */ import { registerTestBed, TestBed, TestBedConfig } from '@kbn/test/jest'; -import { PageContent } from '../../public/application/components/page_content'; +import { DeprecationsOverview } from '../../public/application/components/overview'; import { WithAppDependencies } from './setup_environment'; const testBedConfig: TestBedConfig = { + memoryRouter: { + initialEntries: [`/overview`], + componentRoutePath: '/overview', + }, doMountAsync: true, }; export type OverviewTestBed = TestBed; -export const setup = async (overrides?: any): Promise => { - const initTestBed = registerTestBed(WithAppDependencies(PageContent, overrides), testBedConfig); +export const setup = async (overrides?: Record): Promise => { + const initTestBed = registerTestBed( + WithAppDependencies(DeprecationsOverview, overrides), + testBedConfig + ); const testBed = await initTestBed(); return testBed; }; export type OverviewTestSubjects = - | 'comingSoonPrompt' - | 'upgradeAssistantPageContent' + | 'overviewPageContent' + | 'esStatsPanel' + | 'esStatsPanel.totalDeprecations' + | 'esStatsPanel.criticalDeprecations' + | 'deprecationLoggingFormRow' + | 'requestErrorIconTip' + | 'partiallyUpgradedErrorIconTip' + | 'upgradedErrorIconTip' + | 'unauthorizedErrorIconTip' | 'upgradedPrompt' | 'partiallyUpgradedPrompt' | 'upgradeAssistantDeprecationToggle' - | 'deprecationLoggingStep' | 'upgradeStatusError'; diff --git a/x-pack/plugins/upgrade_assistant/tests_client_integration/helpers/setup_environment.tsx b/x-pack/plugins/upgrade_assistant/tests_client_integration/helpers/setup_environment.tsx index fb0afef8cf5879..7ee6114cd86a80 100644 --- a/x-pack/plugins/upgrade_assistant/tests_client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/upgrade_assistant/tests_client_integration/helpers/setup_environment.tsx @@ -17,14 +17,15 @@ import { mockKibanaSemverVersion, UA_READONLY_MODE } from '../../common/constant import { AppContextProvider } from '../../public/application/app_context'; import { init as initHttpRequests } from './http_requests'; import { apiService } from '../../public/application/lib/api'; +import { breadcrumbService } from '../../public/application/lib/breadcrumbs'; const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); -export const WithAppDependencies = ( - Comp: React.FunctionComponent>, - overrides: Record = {} -) => (props: Record) => { +export const WithAppDependencies = (Comp: any, overrides: Record = {}) => ( + props: Record +) => { apiService.setup((mockHttpClient as unknown) as HttpSetup); + breadcrumbService.setup(() => ''); const contextValue = { http: (mockHttpClient as unknown) as HttpSetup, @@ -38,6 +39,8 @@ export const WithAppDependencies = ( isReadOnlyMode: UA_READONLY_MODE, notifications: notificationServiceMock.createStartContract(), api: apiService, + breadcrumbs: breadcrumbService, + getUrlForApp: () => '', }; return ( diff --git a/x-pack/plugins/upgrade_assistant/tests_client_integration/indices.test.ts b/x-pack/plugins/upgrade_assistant/tests_client_integration/indices.test.ts index 01d95f117827ee..6363e57903c27e 100644 --- a/x-pack/plugins/upgrade_assistant/tests_client_integration/indices.test.ts +++ b/x-pack/plugins/upgrade_assistant/tests_client_integration/indices.test.ts @@ -124,14 +124,7 @@ describe('Indices tab', () => { testBed = await setupIndicesPage({ isReadOnlyMode: false }); }); - const { actions, component } = testBed; - - component.update(); - - // Navigate to the indices tab - await act(async () => { - actions.clickTab('indices'); - }); + const { component } = testBed; component.update(); }); @@ -139,7 +132,7 @@ describe('Indices tab', () => { test('renders prompt', () => { const { exists, find } = testBed; expect(exists('noDeprecationsPrompt')).toBe(true); - expect(find('noDeprecationsPrompt').text()).toContain('All clear!'); + expect(find('noDeprecationsPrompt').text()).toContain('Ready to upgrade!'); }); }); @@ -163,7 +156,59 @@ describe('Indices tab', () => { expect(exists('permissionsError')).toBe(true); expect(find('permissionsError').text()).toContain( - 'You do not have sufficient privileges to view this page.' + 'You are not authorized to view Elasticsearch deprecations.' + ); + }); + + test('handles upgrade error', async () => { + const error = { + statusCode: 426, + error: 'Upgrade required', + message: 'There are some nodes running a different version of Elasticsearch', + attributes: { + allNodesUpgraded: true, + }, + }; + + httpRequestsMockHelpers.setLoadStatusResponse(undefined, error); + + await act(async () => { + testBed = await setupIndicesPage({ isReadOnlyMode: false }); + }); + + const { component, exists, find } = testBed; + + component.update(); + + expect(exists('upgradedCallout')).toBe(true); + expect(find('upgradedCallout').text()).toContain( + 'Your configuration is up to date. Kibana and all Elasticsearch nodes are running the same version.' + ); + }); + + test('handles partially upgrade error', async () => { + const error = { + statusCode: 426, + error: 'Upgrade required', + message: 'There are some nodes running a different version of Elasticsearch', + attributes: { + allNodesUpgraded: false, + }, + }; + + httpRequestsMockHelpers.setLoadStatusResponse(undefined, error); + + await act(async () => { + testBed = await setupIndicesPage({ isReadOnlyMode: false }); + }); + + const { component, exists, find } = testBed; + + component.update(); + + expect(exists('partiallyUpgradedWarning')).toBe(true); + expect(find('partiallyUpgradedWarning').text()).toContain( + 'Upgrade Kibana to the same version as your Elasticsearch cluster. One or more nodes in the cluster is running a different version than Kibana.' ); }); @@ -184,9 +229,9 @@ describe('Indices tab', () => { component.update(); - expect(exists('upgradeStatusError')).toBe(true); - expect(find('upgradeStatusError').text()).toContain( - 'An error occurred while retrieving the checkup results.' + expect(exists('requestError')).toBe(true); + expect(find('requestError').text()).toContain( + 'Could not retrieve Elasticsearch deprecations.' ); }); }); diff --git a/x-pack/plugins/upgrade_assistant/tests_client_integration/overview.test.ts b/x-pack/plugins/upgrade_assistant/tests_client_integration/overview.test.ts index 139c4ecb5a75d3..cdbbd0a36cbdd3 100644 --- a/x-pack/plugins/upgrade_assistant/tests_client_integration/overview.test.ts +++ b/x-pack/plugins/upgrade_assistant/tests_client_integration/overview.test.ts @@ -11,25 +11,9 @@ import { OverviewTestBed, setupOverviewPage, setupEnvironment } from './helpers' describe('Overview page', () => { let testBed: OverviewTestBed; + const { server, httpRequestsMockHelpers } = setupEnvironment(); beforeEach(async () => { - await act(async () => { - testBed = await setupOverviewPage(); - }); - }); - - describe('Coming soon prompt', () => { - // Default behavior up until the last minor before the next major release - test('renders the coming soon prompt by default', () => { - const { exists } = testBed; - - expect(exists('comingSoonPrompt')).toBe(true); - }); - }); - - describe('Overview content', () => { - const { server, httpRequestsMockHelpers } = setupEnvironment(); - const upgradeStatusMockResponse = { readyForUpgrade: false, cluster: [], @@ -39,148 +23,163 @@ describe('Overview page', () => { httpRequestsMockHelpers.setLoadStatusResponse(upgradeStatusMockResponse); httpRequestsMockHelpers.setLoadDeprecationLoggingResponse({ isEnabled: true }); - beforeEach(async () => { - await act(async () => { - // Override the default context value to verify tab content renders as expected - // This will be the default behavior on the last minor before the next major release (e.g., v7.15) - testBed = await setupOverviewPage({ isReadOnlyMode: false }); - }); - - testBed.component.update(); - }); - - afterAll(() => { - server.restore(); + await act(async () => { + testBed = await setupOverviewPage(); }); - test('renders the overview tab', () => { - const { exists } = testBed; + const { component } = testBed; + component.update(); + }); - expect(exists('comingSoonPrompt')).toBe(false); - expect(exists('upgradeAssistantPageContent')).toBe(true); - }); + afterAll(() => { + server.restore(); + }); - describe('Deprecation logging', () => { - test('toggles deprecation logging', async () => { - const { form, find, component } = testBed; + test('renders the overview page', () => { + const { exists, find } = testBed; - httpRequestsMockHelpers.setUpdateDeprecationLoggingResponse({ isEnabled: false }); + expect(exists('overviewPageContent')).toBe(true); + // Verify ES stats + expect(exists('esStatsPanel')).toBe(true); + expect(find('esStatsPanel.totalDeprecations').text()).toContain('0'); + expect(find('esStatsPanel.criticalDeprecations').text()).toContain('0'); + }); - expect(find('upgradeAssistantDeprecationToggle').props()['aria-checked']).toBe(true); - expect(find('upgradeAssistantDeprecationToggle').props().disabled).toBe(false); - expect(find('deprecationLoggingStep').find('.euiSwitch__label').text()).toContain('On'); + describe('Deprecation logging', () => { + test('toggles deprecation logging', async () => { + const { form, find, component } = testBed; - await act(async () => { - form.toggleEuiSwitch('upgradeAssistantDeprecationToggle'); - }); + httpRequestsMockHelpers.setUpdateDeprecationLoggingResponse({ isEnabled: false }); - component.update(); + expect(find('upgradeAssistantDeprecationToggle').props()['aria-checked']).toBe(true); + expect(find('upgradeAssistantDeprecationToggle').props().disabled).toBe(false); - expect(find('upgradeAssistantDeprecationToggle').props()['aria-checked']).toBe(false); - expect(find('upgradeAssistantDeprecationToggle').props().disabled).toBe(false); - expect(find('deprecationLoggingStep').find('.euiSwitch__label').text()).toContain('Off'); + await act(async () => { + form.toggleEuiSwitch('upgradeAssistantDeprecationToggle'); }); - test('handles network error', async () => { - const error = { - statusCode: 500, - error: 'Internal server error', - message: 'Internal server error', - }; + component.update(); - const { form, find, component } = testBed; + expect(find('upgradeAssistantDeprecationToggle').props()['aria-checked']).toBe(false); + expect(find('upgradeAssistantDeprecationToggle').props().disabled).toBe(false); + }); - httpRequestsMockHelpers.setUpdateDeprecationLoggingResponse(undefined, error); + test('handles network error', async () => { + const error = { + statusCode: 500, + error: 'Internal server error', + message: 'Internal server error', + }; - expect(find('upgradeAssistantDeprecationToggle').props()['aria-checked']).toBe(true); - expect(find('upgradeAssistantDeprecationToggle').props().disabled).toBe(false); - expect(find('deprecationLoggingStep').find('.euiSwitch__label').text()).toContain('On'); + const { form, find, component } = testBed; - await act(async () => { - form.toggleEuiSwitch('upgradeAssistantDeprecationToggle'); - }); + httpRequestsMockHelpers.setUpdateDeprecationLoggingResponse(undefined, error); - component.update(); + expect(find('upgradeAssistantDeprecationToggle').props()['aria-checked']).toBe(true); + expect(find('upgradeAssistantDeprecationToggle').props().disabled).toBe(false); + expect(find('deprecationLoggingFormRow').find('.euiSwitch__label').text()).toContain( + 'Enable deprecation logging' + ); - expect(find('upgradeAssistantDeprecationToggle').props()['aria-checked']).toBe(true); - expect(find('upgradeAssistantDeprecationToggle').props().disabled).toBe(true); - expect(find('deprecationLoggingStep').find('.euiSwitch__label').text()).toContain( - 'Could not load logging state' - ); + await act(async () => { + form.toggleEuiSwitch('upgradeAssistantDeprecationToggle'); }); + + component.update(); + + expect(find('upgradeAssistantDeprecationToggle').props()['aria-checked']).toBe(true); + expect(find('upgradeAssistantDeprecationToggle').props().disabled).toBe(true); + expect(find('deprecationLoggingFormRow').find('.euiSwitch__label').text()).toContain( + 'Could not load logging state' + ); }); + }); + + describe('Error handling', () => { + test('handles network failure', async () => { + const error = { + statusCode: 500, + error: 'Internal server error', + message: 'Internal server error', + }; + + httpRequestsMockHelpers.setLoadStatusResponse(undefined, error); + + await act(async () => { + testBed = await setupOverviewPage(); + }); - describe('Error handling', () => { - test('handles network failure', async () => { - const error = { - statusCode: 500, - error: 'Internal server error', - message: 'Internal server error', - }; + const { component, exists } = testBed; - httpRequestsMockHelpers.setLoadStatusResponse(undefined, error); + component.update(); - await act(async () => { - testBed = await setupOverviewPage({ isReadOnlyMode: false }); - }); + expect(exists('requestErrorIconTip')).toBe(true); + }); - const { component, exists, find } = testBed; + test('handles unauthorized error', async () => { + const error = { + statusCode: 403, + error: 'Forbidden', + message: 'Forbidden', + }; - component.update(); + httpRequestsMockHelpers.setLoadStatusResponse(undefined, error); - expect(exists('upgradeStatusError')).toBe(true); - expect(find('upgradeStatusError').text()).toContain( - 'An error occurred while retrieving the checkup results.' - ); + await act(async () => { + testBed = await setupOverviewPage(); }); - test('handles partially upgraded error', async () => { - const error = { - statusCode: 426, - error: 'Upgrade required', - message: 'There are some nodes running a different version of Elasticsearch', - attributes: { - allNodesUpgraded: false, - }, - }; + const { component, exists } = testBed; - httpRequestsMockHelpers.setLoadStatusResponse(undefined, error); + component.update(); - await act(async () => { - testBed = await setupOverviewPage({ isReadOnlyMode: false }); - }); + expect(exists('unauthorizedErrorIconTip')).toBe(true); + }); - const { component, exists, find } = testBed; + test('handles partially upgraded error', async () => { + const error = { + statusCode: 426, + error: 'Upgrade required', + message: 'There are some nodes running a different version of Elasticsearch', + attributes: { + allNodesUpgraded: false, + }, + }; - component.update(); + httpRequestsMockHelpers.setLoadStatusResponse(undefined, error); - expect(exists('partiallyUpgradedPrompt')).toBe(true); - expect(find('partiallyUpgradedPrompt').text()).toContain('Your cluster is upgrading'); + await act(async () => { + testBed = await setupOverviewPage({ isReadOnlyMode: false }); }); - test('handles upgrade error', async () => { - const error = { - statusCode: 426, - error: 'Upgrade required', - message: 'There are some nodes running a different version of Elasticsearch', - attributes: { - allNodesUpgraded: true, - }, - }; + const { component, exists } = testBed; - httpRequestsMockHelpers.setLoadStatusResponse(undefined, error); + component.update(); - await act(async () => { - testBed = await setupOverviewPage({ isReadOnlyMode: false }); - }); + expect(exists('partiallyUpgradedErrorIconTip')).toBe(true); + }); - const { component, exists, find } = testBed; + test('handles upgrade error', async () => { + const error = { + statusCode: 426, + error: 'Upgrade required', + message: 'There are some nodes running a different version of Elasticsearch', + attributes: { + allNodesUpgraded: true, + }, + }; - component.update(); + httpRequestsMockHelpers.setLoadStatusResponse(undefined, error); - expect(exists('upgradedPrompt')).toBe(true); - expect(find('upgradedPrompt').text()).toContain('Your cluster has been upgraded'); + await act(async () => { + testBed = await setupOverviewPage({ isReadOnlyMode: false }); }); + + const { component, exists } = testBed; + + component.update(); + + expect(exists('upgradedErrorIconTip')).toBe(true); }); }); }); diff --git a/x-pack/test/accessibility/apps/upgrade_assistant.ts b/x-pack/test/accessibility/apps/upgrade_assistant.ts index 96b3e6673de706..8d2774c000b29d 100644 --- a/x-pack/test/accessibility/apps/upgrade_assistant.ts +++ b/x-pack/test/accessibility/apps/upgrade_assistant.ts @@ -13,39 +13,39 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const retry = getService('retry'); - describe('Upgrade Assistant Home', () => { + describe('Upgrade Assistant', () => { before(async () => { await PageObjects.upgradeAssistant.navigateToPage(); }); - it('Overview page', async () => { - await retry.waitFor('Upgrade Assistant overview page to be visible', async () => { + it('Coming soon prompt', async () => { + await retry.waitFor('Upgrade Assistant coming soon prompt to be visible', async () => { return testSubjects.exists('comingSoonPrompt'); }); await a11y.testAppSnapshot(); }); // These tests will be skipped until the last minor of the next major release - describe.skip('tabs', () => { - it('Overview Tab', async () => { - await retry.waitFor('Upgrade Assistant overview tab to be visible', async () => { - return testSubjects.exists('upgradeAssistantOverviewTabDetail'); + describe.skip('Upgrade Assistant content', () => { + it('Overview page', async () => { + await retry.waitFor('Upgrade Assistant overview page to be visible', async () => { + return testSubjects.exists('overviewPageContent'); }); await a11y.testAppSnapshot(); }); - it('Cluster Tab', async () => { - await testSubjects.click('upgradeAssistantClusterTab'); + it('Elasticsearch cluster tab', async () => { + await testSubjects.click('esDeprecationsLink'); await retry.waitFor('Upgrade Assistant Cluster tab to be visible', async () => { - return testSubjects.exists('upgradeAssistantClusterTabDetail'); + return testSubjects.exists('clusterTabContent'); }); await a11y.testAppSnapshot(); }); - it('Indices Tab', async () => { + it('Elasticsearch indices tab', async () => { await testSubjects.click('upgradeAssistantIndicesTab'); - await retry.waitFor('Upgrade Assistant Cluster tab to be visible', async () => { - return testSubjects.exists('upgradeAssistantIndexTabDetail'); + await retry.waitFor('Upgrade Assistant Indices tab to be visible', async () => { + return testSubjects.exists('indexTabContent'); }); await a11y.testAppSnapshot(); }); From d679035664a46cd19eeb8d57ca299bacabbd433e Mon Sep 17 00:00:00 2001 From: Greg Thompson Date: Wed, 14 Apr 2021 11:27:36 -0500 Subject: [PATCH 83/90] Upgrade EUI to v32.0.4 (#96459) * eui to 31.12.0 * type updates * snapshot updates * snapshot updates * euiavatarprops * eui to 32.0.3 * euicard updates * update test --- package.json | 2 +- .../header/__snapshots__/header.test.tsx.snap | 82 ++++++++++++++----- src/core/public/chrome/ui/header/header.tsx | 2 +- .../dashboard_empty_screen.test.tsx.snap | 4 + .../__snapshots__/data_view.test.tsx.snap | 8 +- .../apps/discover/_data_grid_doc_table.ts | 4 +- .../List/__snapshots__/List.test.tsx.snap | 2 +- .../custom_element_modal.stories.storyshot | 39 ++------- .../element_card.stories.storyshot | 10 +-- .../element_grid.stories.storyshot | 6 +- .../saved_elements_modal.stories.storyshot | 8 +- .../text_style_picker.stories.storyshot | 48 +++++++---- .../__snapshots__/edit_var.stories.storyshot | 10 ++- .../workpad_templates.stories.storyshot | 2 +- .../epm/screens/detail/policies/persona.tsx | 2 +- .../__snapshots__/policy_table.test.tsx.snap | 1 + .../__snapshots__/add_license.test.js.snap | 4 +- .../request_trial_extension.test.js.snap | 8 +- .../revert_to_basic.test.js.snap | 6 +- .../__snapshots__/start_trial.test.js.snap | 8 +- .../upload_license.test.tsx.snap | 10 +++ .../__snapshots__/no_data.test.js.snap | 2 + .../__snapshots__/page_loading.test.js.snap | 1 + .../__snapshots__/setup_mode.test.js.snap | 10 +-- .../roles_grid_page.test.tsx.snap | 2 + .../nav_control/nav_control_service.test.ts | 26 +++--- .../reset_session_page.test.tsx.snap | 2 +- .../rules/select_rule_type/index.tsx | 5 -- .../__snapshots__/index.test.tsx.snap | 32 ++++---- .../__snapshots__/index.test.tsx.snap | 44 +++++----- .../spaces_grid_pages.test.tsx.snap | 6 -- .../space_avatar_internal.test.tsx.snap | 10 +-- .../space_avatar/space_avatar_internal.tsx | 13 ++- .../location_status_tags.test.tsx.snap | 4 +- yarn.lock | 14 ++-- 35 files changed, 238 insertions(+), 199 deletions(-) diff --git a/package.json b/package.json index 1d31aa627129c1..9b4958c30022c7 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath/npm_module", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.4", "@elastic/ems-client": "7.12.0", - "@elastic/eui": "31.10.0", + "@elastic/eui": "32.0.4", "@elastic/filesaver": "1.1.2", "@elastic/good": "^9.0.1-kibana3", "@elastic/maki": "6.3.0", diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index 00cc827a1e83f1..29407c54e28345 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -4072,8 +4072,34 @@ exports[`Header renders 1`] = ` aria-expanded={false} aria-haspopup="true" aria-label="Help menu" - buttonRef={null} - className="euiHeaderSectionItem__button" + buttonRef={ + Object { + "current": , + } + } + className="euiHeaderSectionItemButton" color="text" onClick={[Function]} > @@ -4081,7 +4107,7 @@ exports[`Header renders 1`] = ` aria-expanded={false} aria-haspopup="true" aria-label="Help menu" - className="euiButtonEmpty euiButtonEmpty--text euiHeaderSectionItem__button" + className="euiButtonEmpty euiButtonEmpty--text euiHeaderSectionItemButton" disabled={false} onClick={[Function]} type="button" @@ -4101,15 +4127,19 @@ exports[`Header renders 1`] = ` - - - + type="help" + > + +
+ @@ -4226,7 +4256,7 @@ exports[`Header renders 1`] = ` aria-expanded="false" aria-label="Toggle primary navigation" aria-pressed="false" - class="euiButtonEmpty euiButtonEmpty--text euiHeaderSectionItem__button" + class="euiButtonEmpty euiButtonEmpty--text euiHeaderSectionItemButton" data-test-subj="toggleNavButton" type="button" > @@ -4237,14 +4267,18 @@ exports[`Header renders 1`] = ` class="euiButtonEmpty__text" > + class="euiHeaderSectionItemButton__content" + > + + , } } - className="euiHeaderSectionItem__button" + className="euiHeaderSectionItemButton" color="text" data-test-subj="toggleNavButton" onClick={[Function]} @@ -4254,7 +4288,7 @@ exports[`Header renders 1`] = ` aria-expanded={false} aria-label="Toggle primary navigation" aria-pressed={false} - className="euiButtonEmpty euiButtonEmpty--text euiHeaderSectionItem__button" + className="euiButtonEmpty euiButtonEmpty--text euiHeaderSectionItemButton" data-test-subj="toggleNavButton" disabled={false} onClick={[Function]} @@ -4275,15 +4309,19 @@ exports[`Header renders 1`] = ` - - - + type="menu" + > + + + diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index 16c89fdca380ab..67cdd24aae8487 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -98,7 +98,7 @@ export function Header({ ); } - const toggleCollapsibleNavRef = createRef(); + const toggleCollapsibleNavRef = createRef void }>(); const navId = htmlIdGenerator()(); const className = classnames('hide-for-sharing', 'headerGlobalNav'); diff --git a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap index 9e3018fb512c37..4cd3eb13f36095 100644 --- a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -617,9 +617,11 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = `
"`; +exports[`RevertToBasic component should display when license is about to expire 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; -exports[`RevertToBasic component should display when license is expired 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; +exports[`RevertToBasic component should display when license is expired 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; -exports[`RevertToBasic component should display when trial is active 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; +exports[`RevertToBasic component should display when trial is active 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap index 9f08c5f11c2a21..1cacadb8246307 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`StartTrial component when trial is allowed display for basic license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; +exports[`StartTrial component when trial is allowed display for basic license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; -exports[`StartTrial component when trial is allowed should display for expired enterprise license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; +exports[`StartTrial component when trial is allowed should display for expired enterprise license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; -exports[`StartTrial component when trial is allowed should display for expired platinum license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; +exports[`StartTrial component when trial is allowed should display for expired platinum license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; -exports[`StartTrial component when trial is allowed should display for gold license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; +exports[`StartTrial component when trial is allowed should display for gold license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap index c89d183282219b..c785ed7c99bda8 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap @@ -268,9 +268,11 @@ exports[`UploadLicense should display a modal when license requires acknowledgem
- + - + renders permission denied if required 1`] = `
{ aria-expanded="false" aria-haspopup="true" aria-label="Account menu" - class="euiButtonEmpty euiButtonEmpty--text euiHeaderSectionItem__button" + class="euiButtonEmpty euiButtonEmpty--text euiHeaderSectionItemButton" data-test-subj="userMenuButton" type="button" > @@ -80,18 +80,22 @@ describe('SecurityNavControlService', () => { -
- -
+ +
+ diff --git a/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap b/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap index bcb8a6c975359a..785c57490e8efd 100644 --- a/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap +++ b/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ResetSessionPage renders as expected 1`] = `"MockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; +exports[`ResetSessionPage renders as expected 1`] = `"MockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx index 64f0f5f65b1eef..5650c2c55488ed 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx @@ -111,7 +111,6 @@ export const SelectRuleType: React.FC = ({ icon={} selectable={querySelectableConfig} layout="horizontal" - textAlign="left" /> )} @@ -131,7 +130,6 @@ export const SelectRuleType: React.FC = ({ isDisabled={mlSelectableConfig.isDisabled && !mlSelectableConfig.isSelected} selectable={mlSelectableConfig} layout="horizontal" - textAlign="left" /> )} @@ -145,7 +143,6 @@ export const SelectRuleType: React.FC = ({ icon={} selectable={thresholdSelectableConfig} layout="horizontal" - textAlign="left" /> )} @@ -159,7 +156,6 @@ export const SelectRuleType: React.FC = ({ icon={} selectable={eqlSelectableConfig} layout="horizontal" - textAlign="left" /> )} @@ -173,7 +169,6 @@ export const SelectRuleType: React.FC = ({ icon={} selectable={threatMatchSelectableConfig} layout="horizontal" - textAlign="left" /> )} diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap index efae0a4b8b3aa9..220494b3a56944 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap @@ -2919,7 +2919,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
`; @@ -44,7 +46,6 @@ exports[`renders with a space name entirely made of whitespace 1`] = ` = (props: Props) => { const spaceColor = getSpaceColor(space); + const spaceInitials = getSpaceInitials(space); + + const spaceImageUrl = getSpaceImageUrl(space); + + const avatarConfig: Partial = spaceImageUrl + ? { imageUrl: spaceImageUrl } + : { initials: spaceInitials, initialsLength: MAX_SPACE_INITIALS }; + return ( = (props: Props) => { 'aria-hidden': true, })} size={size || 'm'} - initialsLength={MAX_SPACE_INITIALS} - initials={getSpaceInitials(space)} color={isValidHex(spaceColor) ? spaceColor : ''} - imageUrl={getSpaceImageUrl(space)} + {...avatarConfig} {...rest} /> ); diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__snapshots__/location_status_tags.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__snapshots__/location_status_tags.test.tsx.snap index 8e2a4b1bd17777..44a2021cce611e 100644 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__snapshots__/location_status_tags.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__snapshots__/location_status_tags.test.tsx.snap @@ -996,7 +996,7 @@ exports[`LocationStatusTags component renders when there are many location 1`] = aria-controls="generated-id" aria-current="true" aria-label="Page 1 of 2" - class="euiButtonEmpty euiButtonEmpty--text euiButtonEmpty--xSmall euiButtonEmpty-isDisabled euiPaginationButton euiPaginationButton-isActive euiPaginationButton--hideOnMobile" + class="euiButtonEmpty euiButtonEmpty--text euiButtonEmpty--small euiButtonEmpty-isDisabled euiPaginationButton euiPaginationButton-isActive euiPaginationButton--hideOnMobile" data-test-subj="pagination-button-0" disabled="" type="button" @@ -1018,7 +1018,7 @@ exports[`LocationStatusTags component renders when there are many location 1`] = Date: Wed, 14 Apr 2021 19:35:23 +0300 Subject: [PATCH 84/90] Update VisualizationNoResults component (#97092) * Update VisualizationNoResults component * update JEST * fix font size --- .../visualization_noresults.test.js.snap | 33 +++++++++---------- .../public/components/visualization_error.tsx | 10 ++++-- .../components/visualization_noresults.tsx | 27 +++++++-------- 3 files changed, 33 insertions(+), 37 deletions(-) diff --git a/src/plugins/visualizations/public/components/__snapshots__/visualization_noresults.test.js.snap b/src/plugins/visualizations/public/components/__snapshots__/visualization_noresults.test.js.snap index 94c5da872b1cb0..25ec05c83a8c6a 100644 --- a/src/plugins/visualizations/public/components/__snapshots__/visualization_noresults.test.js.snap +++ b/src/plugins/visualizations/public/components/__snapshots__/visualization_noresults.test.js.snap @@ -6,32 +6,29 @@ exports[`VisualizationNoResults should render according to snapshot 1`] = ` data-test-subj="visNoResult" >
-
+
+
-
-

+ class="euiText euiText--extraSmall" + > No results found -

+
-
+
-
`; diff --git a/src/plugins/visualizations/public/components/visualization_error.tsx b/src/plugins/visualizations/public/components/visualization_error.tsx index 81600a4e3601c5..c72933df43491a 100644 --- a/src/plugins/visualizations/public/components/visualization_error.tsx +++ b/src/plugins/visualizations/public/components/visualization_error.tsx @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -import { EuiEmptyPrompt } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiText } from '@elastic/eui'; import React from 'react'; interface VisualizationNoResultsProps { onInit?: () => void; - error: string; + error: string | Error; } export class VisualizationError extends React.Component { @@ -21,7 +21,11 @@ export class VisualizationError extends React.Component{this.props.error}

} + body={ + + {typeof this.props.error === 'string' ? this.props.error : this.props.error.message} + + } /> ); } diff --git a/src/plugins/visualizations/public/components/visualization_noresults.tsx b/src/plugins/visualizations/public/components/visualization_noresults.tsx index 92983982dd1529..71bf1e8a7e4b00 100644 --- a/src/plugins/visualizations/public/components/visualization_noresults.tsx +++ b/src/plugins/visualizations/public/components/visualization_noresults.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { EuiIcon, EuiSpacer, EuiText } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; @@ -15,26 +15,21 @@ interface VisualizationNoResultsProps { } export class VisualizationNoResults extends React.Component { - private containerDiv = React.createRef(); - public render() { return ( -
-
-
- - - - - -

+

+ {i18n.translate('visualizations.noResultsFoundTitle', { defaultMessage: 'No results found', })} -

- -
-
+ + } + />
); } From ff7c5330ad97ebfea26dfd37854dcf7117134b8c Mon Sep 17 00:00:00 2001 From: Marshall Main <55718608+marshallmain@users.noreply.github.com> Date: Wed, 14 Apr 2021 12:53:46 -0400 Subject: [PATCH 85/90] [Security Solution] Converge detection engine on single schema representation (#96186) * Replace validation function in signal executor * Remove more RuleTypeParams usage * Add security solution rules migration to alerting plugin * Handle and test null value in threshold.field * Remove runtime normalization of threshold field * Remove signalParamsSchema Co-authored-by: Davis Plumlee Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../server/saved_objects/migrations.test.ts | 276 +++++++++ .../server/saved_objects/migrations.ts | 72 +++ .../schemas/common/schemas.ts | 4 +- .../schemas/request/rule_schemas.ts | 136 ++--- .../response/find_rules_schema.mocks.ts | 16 - .../response/find_rules_schema.test.ts | 128 ----- .../schemas/response/find_rules_schema.ts | 22 - .../schemas/response/index.ts | 1 - .../common/detection_engine/utils.ts | 9 +- .../security_solution/common/validate.ts | 2 +- .../security_solution/cypress/objects/rule.ts | 2 +- .../rules_notification_alert_type.test.ts | 15 +- .../rules_notification_alert_type.ts | 4 +- .../schedule_notification_actions.ts | 4 +- .../notifications/types.test.ts | 5 +- .../routes/__mocks__/request_responses.ts | 113 +--- .../routes/__mocks__/utils.ts | 13 +- .../rules/create_rules_bulk_route.test.ts | 5 +- .../routes/rules/create_rules_bulk_route.ts | 9 +- .../routes/rules/create_rules_route.test.ts | 5 +- .../routes/rules/create_rules_route.ts | 9 +- .../routes/rules/delete_rules_route.test.ts | 5 +- .../routes/rules/delete_rules_route.ts | 15 +- .../routes/rules/find_rules_route.test.ts | 5 +- .../rules/find_rules_status_route.test.ts | 11 +- .../routes/rules/import_rules_route.test.ts | 5 +- .../rules/patch_rules_bulk_route.test.ts | 5 +- .../routes/rules/patch_rules_route.test.ts | 7 +- .../rules/update_rules_bulk_route.test.ts | 5 +- .../routes/rules/update_rules_route.test.ts | 7 +- .../routes/rules/utils.test.ts | 85 +-- .../detection_engine/routes/rules/utils.ts | 76 +-- .../routes/rules/validate.test.ts | 67 +-- .../detection_engine/routes/rules/validate.ts | 58 +- .../lib/detection_engine/routes/utils.test.ts | 7 +- .../detection_engine/rules/create_rules.ts | 3 +- .../lib/detection_engine/rules/find_rules.ts | 4 +- .../get_existing_prepackaged_rules.test.ts | 29 +- .../rules/get_export_all.test.ts | 110 ++-- .../rules/get_export_by_object_ids.test.ts | 45 +- .../rules/get_rules_to_install.test.ts | 11 +- .../rules/get_rules_to_update.test.ts | 51 +- .../rules/patch_rules.mock.ts | 143 +---- .../lib/detection_engine/rules/patch_rules.ts | 15 +- .../detection_engine/rules/read_rules.test.ts | 21 +- .../lib/detection_engine/rules/read_rules.ts | 4 +- .../lib/detection_engine/rules/types.ts | 11 +- .../rules/update_prepacked_rules.ts | 5 +- .../rules/update_rules.test.ts | 9 +- .../detection_engine/rules/update_rules.ts | 12 +- .../schemas/rule_converters.ts | 109 ++-- .../schemas/rule_schemas.mock.ts | 70 ++- .../detection_engine/schemas/rule_schemas.ts | 20 +- .../signals/__mocks__/es_results.ts | 78 +-- .../signals/build_bulk_body.test.ts | 543 ++---------------- .../signals/build_bulk_body.ts | 64 +-- .../signals/build_rule.test.ts | 412 ++----------- .../detection_engine/signals/build_rule.ts | 189 +----- .../signals/bulk_create_ml_signals.ts | 19 +- .../detection_engine/signals/executors/eql.ts | 5 +- .../detection_engine/signals/executors/ml.ts | 17 +- .../signals/executors/query.ts | 17 +- .../signals/executors/threat_match.ts | 17 +- .../signals/executors/threshold.ts | 29 +- .../signals/search_after_bulk_create.test.ts | 151 +---- .../signals/search_after_bulk_create.ts | 33 +- .../signals/send_telemetry_events.ts | 2 - .../signals/signal_params_schema.mock.ts | 55 -- .../signals/signal_params_schema.test.ts | 158 ----- .../signals/signal_params_schema.ts | 86 --- .../signals/signal_rule_alert_type.test.ts | 37 +- .../signals/signal_rule_alert_type.ts | 44 +- .../signals/single_bulk_create.test.ts | 93 +-- .../signals/single_bulk_create.ts | 65 +-- .../threat_mapping/create_threat_signal.ts | 24 +- .../threat_mapping/create_threat_signals.ts | 25 +- .../signals/threat_mapping/types.ts | 36 +- .../bulk_create_threshold_signals.test.ts | 164 +----- .../bulk_create_threshold_signals.ts | 33 +- .../threshold/find_threshold_signals.test.ts | 103 +--- .../threshold/find_threshold_signals.ts | 7 +- .../lib/detection_engine/signals/types.ts | 57 +- .../lib/detection_engine/signals/utils.ts | 21 + .../detection_engine/tags/read_tags.test.ts | 55 +- 84 files changed, 1200 insertions(+), 3319 deletions(-) delete mode 100644 x-pack/plugins/security_solution/common/detection_engine/schemas/response/find_rules_schema.mocks.ts delete mode 100644 x-pack/plugins/security_solution/common/detection_engine/schemas/response/find_rules_schema.test.ts delete mode 100644 x-pack/plugins/security_solution/common/detection_engine/schemas/response/find_rules_schema.ts delete mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.mock.ts delete mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.test.ts delete mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.ts diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts index 676ce1d27d2fcc..4df75ab60b496c 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts @@ -699,6 +699,282 @@ describe('7.11.2', () => { }); }); +describe('7.13.0', () => { + beforeEach(() => { + jest.resetAllMocks(); + encryptedSavedObjectsSetup.createMigration.mockImplementation( + (shouldMigrateWhenPredicate, migration) => migration + ); + }); + test('security solution alerts get migrated and remove null values', () => { + const migration713 = getMigrations(encryptedSavedObjectsSetup)['7.13.0']; + const alert = getMockData({ + alertTypeId: 'siem.signals', + params: { + author: ['Elastic'], + buildingBlockType: null, + description: + "This rule detects a known command and control pattern in network events. The FIN7 threat group is known to use this command and control technique, while maintaining persistence in their target's network.", + ruleId: '4a4e23cf-78a2-449c-bac3-701924c269d3', + index: ['packetbeat-*'], + falsePositives: [ + "This rule could identify benign domains that are formatted similarly to FIN7's command and control algorithm. Alerts should be investigated by an analyst to assess the validity of the individual observations.", + ], + from: 'now-6m', + immutable: true, + query: + 'event.category:(network OR network_traffic) AND type:(tls OR http) AND network.transport:tcp AND destination.domain:/[a-zA-Z]{4,5}.(pw|us|club|info|site|top)/ AND NOT destination.domain:zoom.us', + language: 'lucene', + license: 'Elastic License', + outputIndex: '.siem-signals-rylandherrick_2-default', + savedId: null, + timelineId: null, + timelineTitle: null, + meta: null, + filters: null, + maxSignals: 100, + riskScore: 73, + riskScoreMapping: [], + ruleNameOverride: null, + severity: 'high', + severityMapping: null, + threat: null, + threatFilters: null, + timestampOverride: null, + to: 'now', + type: 'query', + references: [ + 'https://www.fireeye.com/blog/threat-research/2018/08/fin7-pursuing-an-enigmatic-and-evasive-global-criminal-operation.html', + ], + note: + 'In the event this rule identifies benign domains in your environment, the `destination.domain` field in the rule can be modified to include those domains. Example: `...AND NOT destination.domain:(zoom.us OR benign.domain1 OR benign.domain2)`.', + version: 1, + exceptionsList: null, + threshold: { + field: null, + value: 5, + }, + }, + }); + + expect(migration713(alert, migrationContext)).toEqual({ + ...alert, + attributes: { + ...alert.attributes, + params: { + author: ['Elastic'], + description: + "This rule detects a known command and control pattern in network events. The FIN7 threat group is known to use this command and control technique, while maintaining persistence in their target's network.", + ruleId: '4a4e23cf-78a2-449c-bac3-701924c269d3', + index: ['packetbeat-*'], + falsePositives: [ + "This rule could identify benign domains that are formatted similarly to FIN7's command and control algorithm. Alerts should be investigated by an analyst to assess the validity of the individual observations.", + ], + from: 'now-6m', + immutable: true, + query: + 'event.category:(network OR network_traffic) AND type:(tls OR http) AND network.transport:tcp AND destination.domain:/[a-zA-Z]{4,5}.(pw|us|club|info|site|top)/ AND NOT destination.domain:zoom.us', + language: 'lucene', + license: 'Elastic License', + outputIndex: '.siem-signals-rylandherrick_2-default', + maxSignals: 100, + riskScore: 73, + riskScoreMapping: [], + severity: 'high', + severityMapping: [], + threat: [], + to: 'now', + type: 'query', + references: [ + 'https://www.fireeye.com/blog/threat-research/2018/08/fin7-pursuing-an-enigmatic-and-evasive-global-criminal-operation.html', + ], + note: + 'In the event this rule identifies benign domains in your environment, the `destination.domain` field in the rule can be modified to include those domains. Example: `...AND NOT destination.domain:(zoom.us OR benign.domain1 OR benign.domain2)`.', + version: 1, + exceptionsList: [], + threshold: { + field: [], + value: 5, + cardinality: [], + }, + }, + }, + }); + }); + + test('non-null values in security solution alerts are not modified', () => { + const migration713 = getMigrations(encryptedSavedObjectsSetup)['7.13.0']; + const alert = getMockData({ + alertTypeId: 'siem.signals', + params: { + author: ['Elastic'], + buildingBlockType: 'default', + description: + "This rule detects a known command and control pattern in network events. The FIN7 threat group is known to use this command and control technique, while maintaining persistence in their target's network.", + ruleId: '4a4e23cf-78a2-449c-bac3-701924c269d3', + index: ['packetbeat-*'], + falsePositives: [ + "This rule could identify benign domains that are formatted similarly to FIN7's command and control algorithm. Alerts should be investigated by an analyst to assess the validity of the individual observations.", + ], + from: 'now-6m', + immutable: true, + query: + 'event.category:(network OR network_traffic) AND type:(tls OR http) AND network.transport:tcp AND destination.domain:/[a-zA-Z]{4,5}.(pw|us|club|info|site|top)/ AND NOT destination.domain:zoom.us', + language: 'lucene', + license: 'Elastic License', + outputIndex: '.siem-signals-rylandherrick_2-default', + savedId: 'saved-id', + timelineId: 'timeline-id', + timelineTitle: 'timeline-title', + meta: { + field: 'value', + }, + filters: ['filters'], + maxSignals: 100, + riskScore: 73, + riskScoreMapping: ['risk-score-mapping'], + ruleNameOverride: 'field.name', + severity: 'high', + severityMapping: ['severity-mapping'], + threat: [ + { + framework: 'MITRE ATT&CK', + tactic: { + id: 'TA0011', + name: 'Command and Control', + reference: 'https://attack.mitre.org/tactics/TA0011/', + }, + technique: [ + { + id: 'T1483', + name: 'Domain Generation Algorithms', + reference: 'https://attack.mitre.org/techniques/T1483/', + }, + ], + }, + ], + threatFilters: ['threat-filter'], + timestampOverride: 'event.ingested', + to: 'now', + type: 'query', + references: [ + 'https://www.fireeye.com/blog/threat-research/2018/08/fin7-pursuing-an-enigmatic-and-evasive-global-criminal-operation.html', + ], + note: + 'In the event this rule identifies benign domains in your environment, the `destination.domain` field in the rule can be modified to include those domains. Example: `...AND NOT destination.domain:(zoom.us OR benign.domain1 OR benign.domain2)`.', + version: 1, + exceptionsList: ['exceptions-list'], + }, + }); + + expect(migration713(alert, migrationContext)).toEqual(alert); + }); + + test('security solution threshold alert with string in threshold.field is migrated to array', () => { + const migration713 = getMigrations(encryptedSavedObjectsSetup)['7.13.0']; + const alert = getMockData({ + alertTypeId: 'siem.signals', + params: { + threshold: { + field: 'host.id', + value: 5, + }, + }, + }); + + expect(migration713(alert, migrationContext)).toEqual({ + ...alert, + attributes: { + ...alert.attributes, + params: { + threshold: { + field: ['host.id'], + value: 5, + cardinality: [], + }, + exceptionsList: [], + riskScoreMapping: [], + severityMapping: [], + threat: [], + }, + }, + }); + }); + + test('security solution threshold alert with empty string in threshold.field is migrated to empty array', () => { + const migration713 = getMigrations(encryptedSavedObjectsSetup)['7.13.0']; + const alert = getMockData({ + alertTypeId: 'siem.signals', + params: { + threshold: { + field: '', + value: 5, + }, + }, + }); + + expect(migration713(alert, migrationContext)).toEqual({ + ...alert, + attributes: { + ...alert.attributes, + params: { + threshold: { + field: [], + value: 5, + cardinality: [], + }, + exceptionsList: [], + riskScoreMapping: [], + severityMapping: [], + threat: [], + }, + }, + }); + }); + + test('security solution threshold alert with array in threshold.field and cardinality is left alone', () => { + const migration713 = getMigrations(encryptedSavedObjectsSetup)['7.13.0']; + const alert = getMockData({ + alertTypeId: 'siem.signals', + params: { + threshold: { + field: ['host.id'], + value: 5, + cardinality: [ + { + field: 'source.ip', + value: 10, + }, + ], + }, + }, + }); + + expect(migration713(alert, migrationContext)).toEqual({ + ...alert, + attributes: { + ...alert.attributes, + params: { + threshold: { + field: ['host.id'], + value: 5, + cardinality: [ + { + field: 'source.ip', + value: 10, + }, + ], + }, + exceptionsList: [], + riskScoreMapping: [], + severityMapping: [], + threat: [], + }, + }, + }); + }); +}); + function getUpdatedAt(): string { const updatedAt = new Date(); updatedAt.setHours(updatedAt.getHours() + 2); diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.ts index 729290498561f3..8ebeb401b313c6 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.ts @@ -11,6 +11,7 @@ import { SavedObjectMigrationFn, SavedObjectMigrationContext, SavedObjectAttributes, + SavedObjectAttribute, } from '../../../../../src/core/server'; import { RawAlert, RawAlertAction } from '../types'; import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objects/server'; @@ -30,6 +31,9 @@ export const isAnyActionSupportIncidents = (doc: SavedObjectUnsanitizedDoc): boolean => + doc.attributes.alertTypeId === 'siem.signals'; + export function getMigrations( encryptedSavedObjects: EncryptedSavedObjectsPluginSetup ): SavedObjectMigrationMap { @@ -59,10 +63,16 @@ export function getMigrations( pipeMigrations(restructureConnectorsThatSupportIncident) ); + const migrationSecurityRules713 = encryptedSavedObjects.createMigration( + (doc): doc is SavedObjectUnsanitizedDoc => isSecuritySolutionRule(doc), + pipeMigrations(removeNullsFromSecurityRules) + ); + return { '7.10.0': executeMigrationWithErrorHandling(migrationWhenRBACWasIntroduced, '7.10.0'), '7.11.0': executeMigrationWithErrorHandling(migrationAlertUpdatedAtAndNotifyWhen, '7.11.0'), '7.11.2': executeMigrationWithErrorHandling(migrationActions7112, '7.11.2'), + '7.13.0': executeMigrationWithErrorHandling(migrationSecurityRules713, '7.13.0'), }; } @@ -333,6 +343,68 @@ function restructureConnectorsThatSupportIncident( }; } +function convertNullToUndefined(attribute: SavedObjectAttribute) { + return attribute != null ? attribute : undefined; +} + +function removeNullsFromSecurityRules( + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { + const { + attributes: { params }, + } = doc; + return { + ...doc, + attributes: { + ...doc.attributes, + params: { + ...params, + buildingBlockType: convertNullToUndefined(params.buildingBlockType), + note: convertNullToUndefined(params.note), + index: convertNullToUndefined(params.index), + language: convertNullToUndefined(params.language), + license: convertNullToUndefined(params.license), + outputIndex: convertNullToUndefined(params.outputIndex), + savedId: convertNullToUndefined(params.savedId), + timelineId: convertNullToUndefined(params.timelineId), + timelineTitle: convertNullToUndefined(params.timelineTitle), + meta: convertNullToUndefined(params.meta), + query: convertNullToUndefined(params.query), + filters: convertNullToUndefined(params.filters), + riskScoreMapping: params.riskScoreMapping != null ? params.riskScoreMapping : [], + ruleNameOverride: convertNullToUndefined(params.ruleNameOverride), + severityMapping: params.severityMapping != null ? params.severityMapping : [], + threat: params.threat != null ? params.threat : [], + threshold: + params.threshold != null && + typeof params.threshold === 'object' && + !Array.isArray(params.threshold) + ? { + field: Array.isArray(params.threshold.field) + ? params.threshold.field + : params.threshold.field === '' || params.threshold.field == null + ? [] + : [params.threshold.field], + value: params.threshold.value, + cardinality: + params.threshold.cardinality != null ? params.threshold.cardinality : [], + } + : undefined, + timestampOverride: convertNullToUndefined(params.timestampOverride), + exceptionsList: + params.exceptionsList != null + ? params.exceptionsList + : params.exceptions_list != null + ? params.exceptions_list + : params.lists != null + ? params.lists + : [], + threatFilters: convertNullToUndefined(params.threatFilters), + }, + }, + }; +} + function pipeMigrations(...migrations: AlertMigration[]): AlertMigration { return (doc: SavedObjectUnsanitizedDoc) => migrations.reduce((migratedDoc, nextMigration) => nextMigration(migratedDoc), doc); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts index 76ccfb0a433bd9..c61ab85f432709 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts @@ -494,7 +494,7 @@ export const threshold = t.intersection([ thresholdField, t.exact( t.partial({ - cardinality: t.union([t.array(thresholdCardinalityField), t.null]), + cardinality: t.array(thresholdCardinalityField), }) ), ]); @@ -507,7 +507,7 @@ export const thresholdNormalized = t.intersection([ thresholdFieldNormalized, t.exact( t.partial({ - cardinality: t.union([t.array(thresholdCardinalityField), t.null]), + cardinality: t.array(thresholdCardinalityField), }) ), ]); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts index 5cf2b6242b2f89..c7b33372e5953a 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts @@ -57,16 +57,16 @@ import { interval, enabled, updated_at, + updated_by, created_at, + created_by, job_status, status_date, last_success_at, last_success_message, last_failure_at, last_failure_message, - throttleOrNull, - createdByOrNull, - updatedByOrNull, + throttle, } from '../common/schemas'; const createSchema = < @@ -137,7 +137,7 @@ interface APIParams< defaultable: Defaultable; } -const commonParams = { +const baseParams = { required: { name, description, @@ -159,12 +159,11 @@ const commonParams = { tags, interval, enabled, - throttle: throttleOrNull, + throttle, actions, author, false_positives, from, - rule_id, // maxSignals not used in ML rules but probably should be used max_signals, risk_score_mapping, @@ -177,10 +176,26 @@ const commonParams = { }, }; const { - create: commonCreateParams, - patch: commonPatchParams, - response: commonResponseParams, -} = buildAPISchemas(commonParams); + create: baseCreateParams, + patch: basePatchParams, + response: baseResponseParams, +} = buildAPISchemas(baseParams); + +// "shared" types are the same across all rule types, and built from "baseParams" above +// with some variations for each route. These intersect with type specific schemas below +// to create the full schema for each route. +export const sharedCreateSchema = t.intersection([ + baseCreateParams, + t.exact(t.partial({ rule_id })), +]); +export type SharedCreateSchema = t.TypeOf; + +export const sharedUpdateSchema = t.intersection([ + baseCreateParams, + t.exact(t.partial({ rule_id })), + t.exact(t.partial({ id })), +]); +export type SharedUpdateSchema = t.TypeOf; const eqlRuleParams = { required: { @@ -318,74 +333,28 @@ const createTypeSpecific = t.union([ export type CreateTypeSpecific = t.TypeOf; // Convenience types for building specific types of rules -export const eqlCreateSchema = t.intersection([eqlCreateParams, commonCreateParams]); -export type EqlCreateSchema = t.TypeOf; - -export const threatMatchCreateSchema = t.intersection([ - threatMatchCreateParams, - commonCreateParams, -]); -export type ThreatMatchCreateSchema = t.TypeOf; - -export const queryCreateSchema = t.intersection([queryCreateParams, commonCreateParams]); -export type QueryCreateSchema = t.TypeOf; - -export const savedQueryCreateSchema = t.intersection([savedQueryCreateParams, commonCreateParams]); -export type SavedQueryCreateSchema = t.TypeOf; - -export const thresholdCreateSchema = t.intersection([thresholdCreateParams, commonCreateParams]); -export type ThresholdCreateSchema = t.TypeOf; - -export const machineLearningCreateSchema = t.intersection([ - machineLearningCreateParams, - commonCreateParams, -]); -export type MachineLearningCreateSchema = t.TypeOf; - -export const createRulesSchema = t.intersection([commonCreateParams, createTypeSpecific]); +type CreateSchema = SharedCreateSchema & T; +export type EqlCreateSchema = CreateSchema>; +export type ThreatMatchCreateSchema = CreateSchema>; +export type QueryCreateSchema = CreateSchema>; +export type SavedQueryCreateSchema = CreateSchema>; +export type ThresholdCreateSchema = CreateSchema>; +export type MachineLearningCreateSchema = CreateSchema< + t.TypeOf +>; + +export const createRulesSchema = t.intersection([sharedCreateSchema, createTypeSpecific]); export type CreateRulesSchema = t.TypeOf; -export const eqlUpdateSchema = t.intersection([ - eqlCreateParams, - commonCreateParams, - t.exact(t.partial({ id })), -]); -export type EqlUpdateSchema = t.TypeOf; - -export const threatMatchUpdateSchema = t.intersection([ - threatMatchCreateParams, - commonCreateParams, - t.exact(t.partial({ id })), -]); -export type ThreatMatchUpdateSchema = t.TypeOf; - -export const queryUpdateSchema = t.intersection([ - queryCreateParams, - commonCreateParams, - t.exact(t.partial({ id })), -]); -export type QueryUpdateSchema = t.TypeOf; - -export const savedQueryUpdateSchema = t.intersection([ - savedQueryCreateParams, - commonCreateParams, - t.exact(t.partial({ id })), -]); -export type SavedQueryUpdateSchema = t.TypeOf; - -export const thresholdUpdateSchema = t.intersection([ - thresholdCreateParams, - commonCreateParams, - t.exact(t.partial({ id })), -]); -export type ThresholdUpdateSchema = t.TypeOf; - -export const machineLearningUpdateSchema = t.intersection([ - machineLearningCreateParams, - commonCreateParams, - t.exact(t.partial({ id })), -]); -export type MachineLearningUpdateSchema = t.TypeOf; +type UpdateSchema = SharedUpdateSchema & T; +export type EqlUpdateSchema = UpdateSchema>; +export type ThreatMatchUpdateSchema = UpdateSchema>; +export type QueryUpdateSchema = UpdateSchema>; +export type SavedQueryUpdateSchema = UpdateSchema>; +export type ThresholdUpdateSchema = UpdateSchema>; +export type MachineLearningUpdateSchema = UpdateSchema< + t.TypeOf +>; const patchTypeSpecific = t.union([ eqlPatchParams, @@ -406,26 +375,23 @@ const responseTypeSpecific = t.union([ ]); export type ResponseTypeSpecific = t.TypeOf; -export const updateRulesSchema = t.intersection([ - commonCreateParams, - createTypeSpecific, - t.exact(t.partial({ id })), -]); +export const updateRulesSchema = t.intersection([createTypeSpecific, sharedUpdateSchema]); export type UpdateRulesSchema = t.TypeOf; export const fullPatchSchema = t.intersection([ - commonPatchParams, + basePatchParams, patchTypeSpecific, t.exact(t.partial({ id })), ]); const responseRequiredFields = { id, + rule_id, immutable, updated_at, - updated_by: updatedByOrNull, + updated_by, created_at, - created_by: createdByOrNull, + created_by, }; const responseOptionalFields = { status: job_status, @@ -437,7 +403,7 @@ const responseOptionalFields = { }; export const fullResponseSchema = t.intersection([ - commonResponseParams, + baseResponseParams, responseTypeSpecific, t.exact(t.type(responseRequiredFields)), t.exact(t.partial(responseOptionalFields)), diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/find_rules_schema.mocks.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/find_rules_schema.mocks.ts deleted file mode 100644 index 67964a7ab26c39..00000000000000 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/find_rules_schema.mocks.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { FindRulesSchema } from './find_rules_schema'; -import { getRulesSchemaMock } from './rules_schema.mocks'; - -export const getFindRulesSchemaMock = (): FindRulesSchema => ({ - page: 1, - perPage: 1, - total: 1, - data: [getRulesSchemaMock()], -}); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/find_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/find_rules_schema.test.ts deleted file mode 100644 index f9cd405db935db..00000000000000 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/find_rules_schema.test.ts +++ /dev/null @@ -1,128 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { findRulesSchema, FindRulesSchema } from './find_rules_schema'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { left } from 'fp-ts/lib/Either'; -import { RulesSchema } from './rules_schema'; -import { exactCheck } from '../../../exact_check'; -import { foldLeftRight, getPaths } from '../../../test_utils'; -import { getRulesSchemaMock } from './rules_schema.mocks'; -import { getFindRulesSchemaMock } from './find_rules_schema.mocks'; - -describe('find_rules_schema', () => { - test('it should validate a typical single find rules response', () => { - const payload = getFindRulesSchemaMock(); - const decoded = findRulesSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(getFindRulesSchemaMock()); - }); - - test('it should validate an empty find rules response', () => { - const payload = getFindRulesSchemaMock(); - payload.data = []; - const decoded = findRulesSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - const expected = getFindRulesSchemaMock(); - expected.data = []; - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(expected); - }); - - test('it should invalidate a typical single find rules response if it is has an extra property on it', () => { - const payload: FindRulesSchema & { invalid_data?: 'invalid' } = getFindRulesSchemaMock(); - payload.invalid_data = 'invalid'; - const decoded = findRulesSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual(['invalid keys "invalid_data"']); - expect(message.schema).toEqual({}); - }); - - test('it should invalidate a typical single find rules response if the rules are invalid within it', () => { - const payload = getFindRulesSchemaMock(); - const invalidRule: RulesSchema & { invalid_extra_data?: string } = getRulesSchemaMock(); - invalidRule.invalid_extra_data = 'invalid_data'; - payload.data = [invalidRule]; - const decoded = findRulesSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual(['invalid keys "invalid_extra_data"']); - expect(message.schema).toEqual({}); - }); - - test('it should invalidate a typical single find rules response if the rule is missing a required field such as name', () => { - const payload = getFindRulesSchemaMock(); - const invalidRule = getRulesSchemaMock(); - // @ts-expect-error - delete invalidRule.name; - payload.data = [invalidRule]; - const decoded = findRulesSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "name"', - ]); - expect(message.schema).toEqual({}); - }); - - test('it should invalidate a typical single find rules response if it is missing perPage', () => { - const payload = getFindRulesSchemaMock(); - // @ts-expect-error - delete payload.perPage; - const decoded = findRulesSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "perPage"', - ]); - expect(message.schema).toEqual({}); - }); - - test('it should invalidate a typical single find rules response if it has a negative perPage number', () => { - const payload = getFindRulesSchemaMock(); - payload.perPage = -1; - const decoded = findRulesSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual(['Invalid value "-1" supplied to "perPage"']); - expect(message.schema).toEqual({}); - }); - - test('it should invalidate a typical single find rules response if it has a negative page number', () => { - const payload = getFindRulesSchemaMock(); - payload.page = -1; - const decoded = findRulesSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual(['Invalid value "-1" supplied to "page"']); - expect(message.schema).toEqual({}); - }); - - test('it should invalidate a typical single find rules response if it has a negative total', () => { - const payload = getFindRulesSchemaMock(); - payload.total = -1; - const decoded = findRulesSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual(['Invalid value "-1" supplied to "total"']); - expect(message.schema).toEqual({}); - }); -}); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/find_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/find_rules_schema.ts deleted file mode 100644 index c477bc108a7d23..00000000000000 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/find_rules_schema.ts +++ /dev/null @@ -1,22 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import * as t from 'io-ts'; - -import { rulesSchema } from './rules_schema'; -import { page, perPage, total } from '../common/schemas'; - -export const findRulesSchema = t.exact( - t.type({ - page, - perPage, - total, - data: t.array(rulesSchema), - }) -); - -export type FindRulesSchema = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/index.ts index 021cab086438ca..fa8ebaf597f47f 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/index.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/index.ts @@ -6,7 +6,6 @@ */ export * from './error_schema'; -export * from './find_rules_schema'; export * from './import_rules_schema'; export * from './prepackaged_rules_schema'; export * from './prepackaged_rules_status_schema'; diff --git a/x-pack/plugins/security_solution/common/detection_engine/utils.ts b/x-pack/plugins/security_solution/common/detection_engine/utils.ts index a2c362b08dc7a5..1f4e4e140ce186 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/utils.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/utils.ts @@ -12,7 +12,7 @@ import { EntriesArray, ExceptionListItemSchema, } from '../shared_imports'; -import { Type, JobStatus } from './schemas/common/schemas'; +import { Type, JobStatus, Threshold, ThresholdNormalized } from './schemas/common/schemas'; export const hasLargeValueItem = ( exceptionItems: Array @@ -55,5 +55,12 @@ export const normalizeThresholdField = ( : [thresholdField!]; }; +export const normalizeThresholdObject = (threshold: Threshold): ThresholdNormalized => { + return { + ...threshold, + field: normalizeThresholdField(threshold.field), + }; +}; + export const getRuleStatusText = (value: JobStatus | null | undefined): JobStatus | null => value === 'partial failure' ? 'warning' : value != null ? value : null; diff --git a/x-pack/plugins/security_solution/common/validate.ts b/x-pack/plugins/security_solution/common/validate.ts index 79a0351b824e8d..1ac41ecbfb88b5 100644 --- a/x-pack/plugins/security_solution/common/validate.ts +++ b/x-pack/plugins/security_solution/common/validate.ts @@ -27,7 +27,7 @@ export const validate = ( }; export const validateNonExact = ( - obj: object, + obj: unknown, schema: T ): [t.TypeOf | null, string | null] => { const decoded = schema.decode(obj); diff --git a/x-pack/plugins/security_solution/cypress/objects/rule.ts b/x-pack/plugins/security_solution/cypress/objects/rule.ts index 68c7796f7ca3b8..e85b3f45b4ea62 100644 --- a/x-pack/plugins/security_solution/cypress/objects/rule.ts +++ b/x-pack/plugins/security_solution/cypress/objects/rule.ts @@ -332,5 +332,5 @@ export const editedRule = { export const expectedExportedRule = (ruleResponse: Cypress.Response) => { const jsonrule = ruleResponse.body; - return `{"author":[],"actions":[],"created_at":"${jsonrule.created_at}","updated_at":"${jsonrule.updated_at}","created_by":"elastic","description":"${jsonrule.description}","enabled":false,"false_positives":[],"from":"now-17520h","id":"${jsonrule.id}","immutable":false,"index":["exceptions-*"],"interval":"10s","rule_id":"rule_testing","language":"kuery","output_index":".siem-signals-default","max_signals":100,"risk_score":${jsonrule.risk_score},"risk_score_mapping":[],"name":"${jsonrule.name}","query":"${jsonrule.query}","references":[],"severity":"${jsonrule.severity}","severity_mapping":[],"updated_by":"elastic","tags":[],"to":"now","type":"query","threat":[],"throttle":"no_actions","version":1,"exceptions_list":[]}\n{"exported_count":1,"missing_rules":[],"missing_rules_count":0}\n`; + return `{"id":"${jsonrule.id}","updated_at":"${jsonrule.updated_at}","updated_by":"elastic","created_at":"${jsonrule.created_at}","created_by":"elastic","name":"${jsonrule.name}","tags":[],"interval":"10s","enabled":false,"description":"${jsonrule.description}","risk_score":${jsonrule.risk_score},"severity":"${jsonrule.severity}","output_index":".siem-signals-default","author":[],"false_positives":[],"from":"now-17520h","rule_id":"rule_testing","max_signals":100,"risk_score_mapping":[],"severity_mapping":[],"threat":[],"to":"now","references":[],"version":1,"exceptions_list":[],"immutable":false,"type":"query","language":"kuery","index":["exceptions-*"],"query":"${jsonrule.query}","throttle":"no_actions","actions":[]}\n{"exported_count":1,"missing_rules":[],"missing_rules_count":0}\n`; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts index 762d7e724f80a1..8d9779672c3aa7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts @@ -6,7 +6,7 @@ */ import { loggingSystemMock } from 'src/core/server/mocks'; -import { getResult } from '../routes/__mocks__/request_responses'; +import { getAlertMock } from '../routes/__mocks__/request_responses'; import { rulesNotificationAlertType } from './rules_notification_alert_type'; import { buildSignalsSearchQuery } from './build_signals_query'; import { alertsMock, AlertServicesMock } from '../../../../../alerting/server/mocks'; @@ -19,6 +19,7 @@ import { import { DEFAULT_RULE_NOTIFICATION_QUERY_SIZE } from '../../../../common/constants'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; +import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; jest.mock('./build_signals_query'); describe('rules_notification_alert_type', () => { @@ -65,7 +66,7 @@ describe('rules_notification_alert_type', () => { }); it('should call buildSignalsSearchQuery with proper params', async () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); alertServices.savedObjectsClient.get.mockResolvedValue({ id: 'id', type: 'type', @@ -92,7 +93,7 @@ describe('rules_notification_alert_type', () => { }); it('should resolve results_link when meta is undefined to use "/app/security"', async () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); delete ruleAlert.params.meta; alertServices.savedObjectsClient.get.mockResolvedValue({ id: 'rule-id', @@ -120,7 +121,7 @@ describe('rules_notification_alert_type', () => { }); it('should resolve results_link when meta is an empty object to use "/app/security"', async () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); ruleAlert.params.meta = {}; alertServices.savedObjectsClient.get.mockResolvedValue({ id: 'rule-id', @@ -147,7 +148,7 @@ describe('rules_notification_alert_type', () => { }); it('should resolve results_link to custom kibana link when given one', async () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); ruleAlert.params.meta = { kibana_siem_app_url: 'http://localhost', }; @@ -176,7 +177,7 @@ describe('rules_notification_alert_type', () => { }); it('should not call alertInstanceFactory if signalsCount was 0', async () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); alertServices.savedObjectsClient.get.mockResolvedValue({ id: 'id', type: 'type', @@ -193,7 +194,7 @@ describe('rules_notification_alert_type', () => { }); it('should call scheduleActions if signalsCount was greater than 0', async () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); alertServices.savedObjectsClient.get.mockResolvedValue({ id: 'id', type: 'type', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts index 799fb3814f1f0d..c1393924e3d29e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts @@ -14,7 +14,7 @@ import { } from '../../../../common/constants'; import { NotificationAlertTypeDefinition } from './types'; -import { RuleAlertAttributes } from '../signals/types'; +import { AlertAttributes } from '../signals/types'; import { siemRuleActionGroups } from '../signals/siem_rule_action_groups'; import { scheduleNotificationActions } from './schedule_notification_actions'; import { getNotificationResultsLink } from './utils'; @@ -38,7 +38,7 @@ export const rulesNotificationAlertType = ({ }, minimumLicenseRequired: 'basic', async executor({ startedAt, previousStartedAt, alertId, services, params }) { - const ruleAlertSavedObject = await services.savedObjectsClient.get( + const ruleAlertSavedObject = await services.savedObjectsClient.get( 'alert', params.ruleAlertId ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.ts index 729de70b5f9c49..e7db10380eea11 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.ts @@ -7,10 +7,10 @@ import { mapKeys, snakeCase } from 'lodash/fp'; import { AlertInstance } from '../../../../../alerting/server'; +import { RuleParams } from '../schemas/rule_schemas'; import { SignalSource } from '../signals/types'; -import { RuleTypeParams } from '../types'; -export type NotificationRuleTypeParams = RuleTypeParams & { +export type NotificationRuleTypeParams = RuleParams & { name: string; id: string; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/types.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/types.test.ts index 0eb4cf70935d03..a8678c664f3315 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/types.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/types.test.ts @@ -6,9 +6,10 @@ */ import { loggingSystemMock } from 'src/core/server/mocks'; -import { getNotificationResult, getResult } from '../routes/__mocks__/request_responses'; +import { getNotificationResult, getAlertMock } from '../routes/__mocks__/request_responses'; import { isAlertTypes, isNotificationAlertExecutor } from './types'; import { rulesNotificationAlertType } from './rules_notification_alert_type'; +import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; describe('types', () => { it('isAlertTypes should return true if is RuleNotificationAlertType type', () => { @@ -16,7 +17,7 @@ describe('types', () => { }); it('isAlertTypes should return false if is not RuleNotificationAlertType', () => { - expect(isAlertTypes([getResult()])).toEqual(false); + expect(isAlertTypes([getAlertMock(getQueryRuleParams())])).toEqual(false); }); it('isNotificationAlertExecutor should return true it passed object is NotificationAlertTypeDefinition type', () => { 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 649ce9ed643651..43377251019176 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 @@ -31,10 +31,11 @@ import { QuerySignalsSchemaDecoded } from '../../../../../common/detection_engin import { SetSignalsStatusSchemaDecoded } from '../../../../../common/detection_engine/schemas/request/set_signal_status_schema'; import { getCreateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/rule_schemas.mock'; import { getFinalizeSignalsMigrationSchemaMock } from '../../../../../common/detection_engine/schemas/request/finalize_signals_migration_schema.mock'; -import { getListArrayMock } from '../../../../../common/detection_engine/schemas/types/lists.mock'; import { EqlSearchResponse } from '../../../../../common/detection_engine/types'; -import { getThreatMock } from '../../../../../common/detection_engine/schemas/types/threat.mock'; import { getSignalsMigrationStatusSchemaMock } from '../../../../../common/detection_engine/schemas/request/get_signals_migration_status_schema.mock'; +import { RuleParams } from '../../schemas/rule_schemas'; +import { Alert } from '../../../../../../alerting/common'; +import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; export const typicalSetStatusSignalByIdsPayload = (): SetSignalsStatusSchemaDecoded => ({ signal_ids: ['somefakeid1', 'somefakeid2'], @@ -171,7 +172,7 @@ export const getFindResultWithSingleHit = (): FindHit => ({ page: 1, perPage: 1, total: 1, - data: [getResult()], + data: [getAlertMock(getQueryRuleParams())], }); export const nonRuleFindResult = (): FindHit => ({ @@ -337,71 +338,20 @@ export const createActionResult = (): ActionResult => ({ }); export const nonRuleAlert = () => ({ - ...getResult(), + // Defaulting to QueryRuleParams because ts doesn't like empty objects + ...getAlertMock(getQueryRuleParams()), id: '04128c15-0d1b-4716-a4c5-46997ac7f3bc', name: 'Non-Rule Alert', alertTypeId: 'something', }); -export const getResult = (): RuleAlertType => ({ +export const getAlertMock = (params: T): Alert => ({ id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', name: 'Detect Root/Admin Users', tags: [`${INTERNAL_RULE_ID_KEY}:rule-1`, `${INTERNAL_IMMUTABLE_KEY}:false`], alertTypeId: 'siem.signals', consumer: 'siem', - params: { - author: ['Elastic'], - buildingBlockType: undefined, - anomalyThreshold: undefined, - description: 'Detecting root and admin users', - ruleId: 'rule-1', - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - eventCategoryOverride: undefined, - falsePositives: [], - from: 'now-6m', - immutable: false, - savedId: undefined, - query: 'user.name: root or user.name: admin', - language: 'kuery', - license: 'Elastic License', - machineLearningJobId: undefined, - outputIndex: '.siem-signals', - timelineId: 'some-timeline-id', - timelineTitle: 'some-timeline-title', - meta: { someMeta: 'someField' }, - filters: [ - { - query: { - match_phrase: { - 'host.name': 'some-host', - }, - }, - }, - ], - riskScore: 50, - riskScoreMapping: [], - ruleNameOverride: undefined, - maxSignals: 100, - severity: 'high', - severityMapping: [], - to: 'now', - type: 'query', - threat: getThreatMock(), - threshold: undefined, - timestampOverride: undefined, - threatFilters: undefined, - threatMapping: undefined, - threatLanguage: undefined, - threatIndex: undefined, - threatIndicatorPath: undefined, - threatQuery: undefined, - references: ['http://www.example.com', 'https://ww.example.com'], - note: '# Investigative notes', - version: 1, - exceptionsList: getListArrayMock(), - concurrentSearches: undefined, - itemsPerSearch: undefined, - }, + params, createdAt: new Date('2019-12-13T16:40:33.400Z'), updatedAt: new Date('2019-12-13T16:40:33.400Z'), schedule: { interval: '5m' }, @@ -422,53 +372,6 @@ export const getResult = (): RuleAlertType => ({ }, }); -export const getMlResult = (): RuleAlertType => { - const result = getResult(); - - return { - ...result, - params: { - ...result.params, - query: undefined, - language: undefined, - filters: undefined, - index: undefined, - type: 'machine_learning', - anomalyThreshold: 44, - machineLearningJobId: 'some_job_id', - }, - }; -}; - -export const getThresholdResult = (): RuleAlertType => { - const result = getResult(); - - return { - ...result, - params: { - ...result.params, - type: 'threshold', - threshold: { - field: 'host.ip', - value: 5, - }, - }, - }; -}; - -export const getEqlResult = (): RuleAlertType => { - const result = getResult(); - - return { - ...result, - params: { - ...result.params, - type: 'eql', - query: 'process where true', - }, - }; -}; - export const updateActionResult = (): ActionResult => ({ id: 'result-1', actionTypeId: 'action-id-1', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts index 662be3e8c7ab4f..0dcecf3fe37895 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts @@ -40,6 +40,7 @@ export const getOutputRuleAlertForRest = (): Omit< > => ({ author: ['Elastic'], actions: [], + building_block_type: 'default', created_by: 'elastic', created_at: '2019-12-13T16:40:33.400Z', updated_at: '2019-12-13T16:40:33.400Z', @@ -54,15 +55,23 @@ export const getOutputRuleAlertForRest = (): Omit< risk_score: 50, risk_score_mapping: [], rule_id: 'rule-1', + rule_name_override: undefined, + saved_id: undefined, language: 'kuery', + last_failure_at: undefined, + last_failure_message: undefined, + last_success_at: undefined, + last_success_message: undefined, license: 'Elastic License', - max_signals: 100, + max_signals: 10000, name: 'Detect Root/Admin Users', output_index: '.siem-signals', query: 'user.name: root or user.name: admin', - references: ['http://www.example.com', 'https://ww.example.com'], + references: ['http://example.com', 'https://example.com'], severity: 'high', severity_mapping: [], + status: undefined, + status_date: undefined, updated_by: 'elastic', tags: [], throttle: 'no_actions', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts index ef7236084508d2..311e2fcc41a0b8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts @@ -13,7 +13,7 @@ import { getNonEmptyIndex, getFindResultWithSingleHit, getEmptyFindResult, - getResult, + getAlertMock, createBulkMlRuleRequest, } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; @@ -21,6 +21,7 @@ import { createRulesBulkRoute } from './create_rules_bulk_route'; import { getCreateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/rule_schemas.mock'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; +import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); @@ -36,7 +37,7 @@ describe('create_rules_bulk', () => { clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex()); // index exists clients.alertsClient.find.mockResolvedValue(getEmptyFindResult()); // no existing rules - clients.alertsClient.create.mockResolvedValue(getResult()); // successful creation + clients.alertsClient.create.mockResolvedValue(getAlertMock(getQueryRuleParams())); // successful creation // eslint-disable-next-line @typescript-eslint/no-explicit-any (context.core.elasticsearch.client.asCurrentUser.search as any).mockResolvedValue( 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 e54c9a4cbb03e6..cd0e1883e78f52 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 @@ -23,8 +23,6 @@ import { buildRouteValidation } from '../../../../utils/build_validation/route_v import { transformBulkError, createBulkErrorObject, buildSiemResponse } from '../utils'; import { updateRulesNotifications } from '../../rules/update_rules_notifications'; import { convertCreateAPIToInternalSchema } from '../../schemas/rule_converters'; -import { RuleTypeParams } from '../../types'; -import { Alert } from '../../../../../../alerting/common'; export const createRulesBulkRoute = ( router: SecuritySolutionPluginRouter, @@ -101,12 +99,9 @@ export const createRulesBulkRoute = ( }); } - /** - * TODO: Remove this use of `as` by utilizing the proper type - */ - const createdRule = (await alertsClient.create({ + const createdRule = await alertsClient.create({ data: internalRule, - })) as Alert; + }); const ruleActions = await updateRulesNotifications({ ruleAlertId: createdRule.id, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts index d6693dc1f7a0bd..b04f178363f998 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts @@ -8,7 +8,7 @@ import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { getEmptyFindResult, - getResult, + getAlertMock, getCreateRequest, getFindResultStatus, getNonEmptyIndex, @@ -23,6 +23,7 @@ import { updateRulesNotifications } from '../../rules/update_rules_notifications import { getCreateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/rule_schemas.mock'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; +import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; jest.mock('../../rules/update_rules_notifications'); jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); @@ -38,7 +39,7 @@ describe('create_rules', () => { clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex()); // index exists clients.alertsClient.find.mockResolvedValue(getEmptyFindResult()); // no current rules - clients.alertsClient.create.mockResolvedValue(getResult()); // creation succeeds + clients.alertsClient.create.mockResolvedValue(getAlertMock(getQueryRuleParams())); // creation succeeds clients.savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); // needed to transform // eslint-disable-next-line @typescript-eslint/no-explicit-any 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 95539319b5a122..1e34bbbbe47498 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 @@ -20,8 +20,6 @@ import { createRulesSchema } from '../../../../../common/detection_engine/schema import { newTransformValidate } from './validate'; import { createRuleValidateTypeDependents } from '../../../../../common/detection_engine/schemas/request/create_rules_type_dependents'; import { convertCreateAPIToInternalSchema } from '../../schemas/rule_converters'; -import { RuleTypeParams } from '../../types'; -import { Alert } from '../../../../../../alerting/common'; export const createRulesRoute = ( router: SecuritySolutionPluginRouter, @@ -91,12 +89,9 @@ export const createRulesRoute = ( // This will create the endpoint list if it does not exist yet await context.lists?.getExceptionListClient().createEndpointList(); - /** - * TODO: Remove this use of `as` by utilizing the proper type - */ - const createdRule = (await alertsClient.create({ + const createdRule = await alertsClient.create({ data: internalRule, - })) as Alert; + }); const ruleActions = await updateRulesNotifications({ ruleAlertId: createdRule.id, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.test.ts index 72aec9471c4a07..e820487dc0c5d8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.test.ts @@ -8,7 +8,7 @@ import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { getEmptyFindResult, - getResult, + getAlertMock, getDeleteRequest, getFindResultWithSingleHit, getDeleteRequestById, @@ -16,6 +16,7 @@ import { } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { deleteRulesRoute } from './delete_rules_route'; +import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; describe('delete_rules', () => { let server: ReturnType; @@ -39,7 +40,7 @@ describe('delete_rules', () => { }); test('returns 200 when deleting a single rule with a valid actionClient and alertClient by id', async () => { - clients.alertsClient.get.mockResolvedValue(getResult()); + clients.alertsClient.get.mockResolvedValue(getAlertMock(getQueryRuleParams())); const response = await server.inject(getDeleteRequestById(), context); expect(response.status).toEqual(200); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.ts index d48eb0ddfa59d3..3bd7c7f8730b3d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.ts @@ -14,8 +14,7 @@ import { buildRouteValidation } from '../../../../utils/build_validation/route_v import type { SecuritySolutionPluginRouter } from '../../../../types'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { deleteRules } from '../../rules/delete_rules'; -import { getIdError } from './utils'; -import { transformValidate } from './validate'; +import { getIdError, transform } from './utils'; import { transformError, buildSiemResponse } from '../utils'; import { deleteNotifications } from '../../notifications/delete_notifications'; import { deleteRuleActionsSavedObject } from '../../rule_actions/delete_rule_actions_saved_object'; @@ -69,15 +68,11 @@ export const deleteRulesRoute = (router: SecuritySolutionPluginRouter) => { searchFields: ['alertId'], }); ruleStatuses.saved_objects.forEach(async (obj) => ruleStatusClient.delete(obj.id)); - const [validated, errors] = transformValidate( - rule, - undefined, - ruleStatuses.saved_objects[0] - ); - if (errors != null) { - return siemResponse.error({ statusCode: 500, body: errors }); + const transformed = transform(rule, undefined, ruleStatuses.saved_objects[0]); + if (transformed == null) { + return siemResponse.error({ statusCode: 500, body: 'failed to transform alert' }); } else { - return response.ok({ body: validated ?? {} }); + return response.ok({ body: transformed ?? {} }); } } else { const error = getIdError({ id, ruleId }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_route.test.ts index f44df412b7fb1e..434ef0f88b1969 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_route.test.ts @@ -7,13 +7,14 @@ import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { - getResult, + getAlertMock, getFindRequest, getFindResultWithSingleHit, getFindResultStatus, } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { findRulesRoute } from './find_rules_route'; +import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; jest.mock('../../signals/rule_status_service'); describe('find_rules', () => { @@ -25,7 +26,7 @@ describe('find_rules', () => { ({ clients, context } = requestContextMock.createTools()); clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); - clients.alertsClient.get.mockResolvedValue(getResult()); + clients.alertsClient.get.mockResolvedValue(getAlertMock(getQueryRuleParams())); clients.savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); findRulesRoute(server.router); 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 33d566ab6f0c74..c3a53a1f393ec5 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 @@ -6,11 +6,16 @@ */ import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; -import { getFindResultStatus, ruleStatusRequest, getResult } from '../__mocks__/request_responses'; +import { + getFindResultStatus, + ruleStatusRequest, + getAlertMock, +} from '../__mocks__/request_responses'; import { serverMock, requestContextMock, requestMock } from '../__mocks__'; import { findRulesStatusesRoute } from './find_rules_status_route'; import { RuleStatusResponse } from '../../rules/types'; import { AlertExecutionStatusErrorReasons } from '../../../../../../alerting/common'; +import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; jest.mock('../../signals/rule_status_service'); @@ -22,7 +27,7 @@ describe('find_statuses', () => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); clients.savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); // successful status search - clients.alertsClient.get.mockResolvedValue(getResult()); + clients.alertsClient.get.mockResolvedValue(getAlertMock(getQueryRuleParams())); findRulesStatusesRoute(server.router); }); @@ -54,7 +59,7 @@ describe('find_statuses', () => { test('returns success if rule status client writes an error status', async () => { // 0. task manager tried to run the rule but couldn't, so the alerting framework // wrote an error to the executionStatus. - const failingExecutionRule = getResult(); + const failingExecutionRule = getAlertMock(getQueryRuleParams()); failingExecutionRule.executionStatus = { status: 'error', lastExecutionDate: failingExecutionRule.executionStatus.lastExecutionDate, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts index b0b42326518031..0a680d1b0d1c11 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts @@ -10,7 +10,7 @@ import { getImportRulesRequest, getImportRulesRequestOverwriteTrue, getEmptyFindResult, - getResult, + getAlertMock, getFindResultWithSingleHit, getNonEmptyIndex, } from '../__mocks__/request_responses'; @@ -26,6 +26,7 @@ import { } from '../../../../../common/detection_engine/schemas/request/import_rules_schema.mock'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; +import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); @@ -170,7 +171,7 @@ describe('import_rules_route', () => { describe('single rule import', () => { test('returns 200 if rule imported successfully', async () => { - clients.alertsClient.create.mockResolvedValue(getResult()); + clients.alertsClient.create.mockResolvedValue(getAlertMock(getQueryRuleParams())); const response = await server.inject(request, context); expect(response.status).toEqual(200); expect(response.body).toEqual({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts index 93fdf9c5f81944..b83dad92d43b5d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts @@ -12,12 +12,13 @@ import { getEmptyFindResult, getFindResultWithSingleHit, getPatchBulkRequest, - getResult, + getAlertMock, typicalMlRulePayload, } from '../__mocks__/request_responses'; import { serverMock, requestContextMock, requestMock } from '../__mocks__'; import { patchRulesBulkRoute } from './patch_rules_bulk_route'; import { getCreateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/rule_schemas.mock'; +import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); @@ -32,7 +33,7 @@ describe('patch_rules_bulk', () => { ml = mlServicesMock.createSetupContract(); clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); // rule exists - clients.alertsClient.update.mockResolvedValue(getResult()); // update succeeds + clients.alertsClient.update.mockResolvedValue(getAlertMock(getQueryRuleParams())); // update succeeds patchRulesBulkRoute(server.router, ml); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts index 6e62f65f44858f..2fa72ae2a097ee 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts @@ -11,7 +11,7 @@ import { buildMlAuthz } from '../../../machine_learning/authz'; import { getEmptyFindResult, getFindResultStatus, - getResult, + getAlertMock, getPatchRequest, getFindResultWithSingleHit, nonRuleFindResult, @@ -20,6 +20,7 @@ import { import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { patchRulesRoute } from './patch_rules_route'; import { getPatchRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/patch_rules_schema.mock'; +import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); @@ -33,9 +34,9 @@ describe('patch_rules', () => { ({ clients, context } = requestContextMock.createTools()); ml = mlServicesMock.createSetupContract(); - clients.alertsClient.get.mockResolvedValue(getResult()); // existing rule + clients.alertsClient.get.mockResolvedValue(getAlertMock(getQueryRuleParams())); // existing rule clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); // existing rule - clients.alertsClient.update.mockResolvedValue(getResult()); // successful update + clients.alertsClient.update.mockResolvedValue(getAlertMock(getQueryRuleParams())); // successful update clients.savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); // successful transform patchRulesRoute(server.router, ml); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts index 41b31b04e3424b..a57bed7a895f9f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts @@ -10,7 +10,7 @@ import { mlServicesMock, mlAuthzMock as mockMlAuthzFactory } from '../../../mach import { buildMlAuthz } from '../../../machine_learning/authz'; import { getEmptyFindResult, - getResult, + getAlertMock, getFindResultWithSingleHit, getUpdateBulkRequest, getFindResultStatus, @@ -20,6 +20,7 @@ import { serverMock, requestContextMock, requestMock } from '../__mocks__'; import { updateRulesBulkRoute } from './update_rules_bulk_route'; import { BulkError } from '../utils'; import { getCreateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/rule_schemas.mock'; +import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); @@ -34,7 +35,7 @@ describe('update_rules_bulk', () => { ml = mlServicesMock.createSetupContract(); clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); - clients.alertsClient.update.mockResolvedValue(getResult()); + clients.alertsClient.update.mockResolvedValue(getAlertMock(getQueryRuleParams())); clients.savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); updateRulesBulkRoute(server.router, ml); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts index c80d32e09ccab5..cf121d1610d39b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts @@ -9,7 +9,7 @@ import { mlServicesMock, mlAuthzMock as mockMlAuthzFactory } from '../../../mach import { buildMlAuthz } from '../../../machine_learning/authz'; import { getEmptyFindResult, - getResult, + getAlertMock, getUpdateRequest, getFindResultWithSingleHit, getFindResultStatusEmpty, @@ -21,6 +21,7 @@ import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { updateRulesNotifications } from '../../rules/update_rules_notifications'; import { updateRulesRoute } from './update_rules_route'; import { getUpdateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/rule_schemas.mock'; +import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); jest.mock('../../rules/update_rules_notifications'); @@ -35,9 +36,9 @@ describe('update_rules', () => { ({ clients, context } = requestContextMock.createTools()); ml = mlServicesMock.createSetupContract(); - clients.alertsClient.get.mockResolvedValue(getResult()); // existing rule + clients.alertsClient.get.mockResolvedValue(getAlertMock(getQueryRuleParams())); // existing rule clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); // rule exists - clients.alertsClient.update.mockResolvedValue(getResult()); // successful update + clients.alertsClient.update.mockResolvedValue(getAlertMock(getQueryRuleParams())); // successful update clients.savedObjectsClient.find.mockResolvedValue(getFindResultStatusEmpty()); // successful transform updateRulesRoute(server.router, ml); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts index cf7d2e9eea2fa8..ffa699daf9c952 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts @@ -21,9 +21,9 @@ import { getDuplicates, getTupleDuplicateErrorsAndUniqueRules, } from './utils'; -import { getResult } from '../__mocks__/request_responses'; +import { getAlertMock } from '../__mocks__/request_responses'; import { INTERNAL_IDENTIFIER } from '../../../../../common/constants'; -import { PartialFilter, RuleTypeParams } from '../../types'; +import { PartialFilter } from '../../types'; import { BulkError, ImportSuccessError } from '../utils'; import { getOutputRuleAlertForRest } from '../__mocks__/utils'; import { PartialAlert } from '../../../../../../alerting/server'; @@ -34,58 +34,32 @@ import { ImportRulesSchemaDecoded } from '../../../../../common/detection_engine import { getCreateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/rule_schemas.mock'; import { ThreatMapping } from '../../../../../common/detection_engine/schemas/types/threat_mapping'; import { CreateRulesBulkSchema } from '../../../../../common/detection_engine/schemas/request'; +import { + getMlRuleParams, + getQueryRuleParams, + getThreatRuleParams, +} from '../../schemas/rule_schemas.mock'; type PromiseFromStreams = ImportRulesSchemaDecoded | Error; describe('utils', () => { describe('transformAlertToRule', () => { test('should work with a full data set', () => { - const fullRule = getResult(); + const fullRule = getAlertMock(getQueryRuleParams()); const rule = transformAlertToRule(fullRule); expect(rule).toEqual(getOutputRuleAlertForRest()); }); - test('should work with a partial data set missing data', () => { - const fullRule = getResult(); - const { from, language, ...omitParams } = fullRule.params; - fullRule.params = omitParams as RuleTypeParams; + test('should omit note if note is undefined', () => { + const fullRule = getAlertMock(getQueryRuleParams()); + fullRule.params.note = undefined; const rule = transformAlertToRule(fullRule); - const { - from: from2, - language: language2, - ...expectedWithoutFromWithoutLanguage - } = getOutputRuleAlertForRest(); - expect(rule).toEqual(expectedWithoutFromWithoutLanguage); - }); - - test('should omit query if query is undefined', () => { - const fullRule = getResult(); - fullRule.params.query = undefined; - const rule = transformAlertToRule(fullRule); - const { query, ...expectedWithoutQuery } = getOutputRuleAlertForRest(); - expect(rule).toEqual(expectedWithoutQuery); - }); - - test('should omit a mix of undefined, null, and missing fields', () => { - const fullRule = getResult(); - fullRule.params.query = undefined; - fullRule.params.language = undefined; - const { from, ...omitParams } = fullRule.params; - fullRule.params = omitParams as RuleTypeParams; - const { enabled, ...omitEnabled } = fullRule; - const rule = transformAlertToRule(omitEnabled as RuleAlertType); - const { - from: from2, - enabled: enabled2, - language, - query, - ...expectedWithoutFromEnabledLanguageQuery - } = getOutputRuleAlertForRest(); - expect(rule).toEqual(expectedWithoutFromEnabledLanguageQuery); + const { note, ...expectedWithoutNote } = getOutputRuleAlertForRest(); + expect(rule).toEqual(expectedWithoutNote); }); test('should return enabled is equal to false', () => { - const fullRule = getResult(); + const fullRule = getAlertMock(getQueryRuleParams()); fullRule.enabled = false; const ruleWithEnabledFalse = transformAlertToRule(fullRule); const expected = getOutputRuleAlertForRest(); @@ -94,7 +68,7 @@ describe('utils', () => { }); test('should return immutable is equal to false', () => { - const fullRule = getResult(); + const fullRule = getAlertMock(getQueryRuleParams()); fullRule.params.immutable = false; const ruleWithEnabledFalse = transformAlertToRule(fullRule); const expected = getOutputRuleAlertForRest(); @@ -102,7 +76,7 @@ describe('utils', () => { }); test('should work with tags but filter out any internal tags', () => { - const fullRule = getResult(); + const fullRule = getAlertMock(getQueryRuleParams()); fullRule.tags = ['tag 1', 'tag 2', `${INTERNAL_IDENTIFIER}_some_other_value`]; const rule = transformAlertToRule(fullRule); const expected = getOutputRuleAlertForRest(); @@ -111,7 +85,7 @@ describe('utils', () => { }); test('transforms ML Rule fields', () => { - const mlRule = getResult(); + const mlRule = getAlertMock(getMlRuleParams()); mlRule.params.anomalyThreshold = 55; mlRule.params.machineLearningJobId = 'some_job_id'; mlRule.params.type = 'machine_learning'; @@ -127,7 +101,7 @@ describe('utils', () => { }); test('transforms threat_matching fields', () => { - const threatRule = getResult(); + const threatRule = getAlertMock(getThreatRuleParams()); const threatFilters: PartialFilter[] = [ { query: { @@ -178,7 +152,10 @@ describe('utils', () => { // This has to stay here until we do data migration of saved objects and lists is removed from: // signal_params_schema.ts test('does not leak a lists structure in the transform which would cause validation issues', () => { - const result: RuleAlertType & { lists: [] } = { lists: [], ...getResult() }; + const result: RuleAlertType & { lists: [] } = { + lists: [], + ...getAlertMock(getQueryRuleParams()), + }; const rule = transformAlertToRule(result); expect(rule).toEqual( expect.not.objectContaining({ @@ -192,7 +169,7 @@ describe('utils', () => { test('does not leak an exceptions_list structure in the transform which would cause validation issues', () => { const result: RuleAlertType & { exceptions_list: [] } = { exceptions_list: [], - ...getResult(), + ...getAlertMock(getQueryRuleParams()), }; const rule = transformAlertToRule(result); expect(rule).toEqual( @@ -289,7 +266,7 @@ describe('utils', () => { page: 1, perPage: 0, total: 0, - data: [getResult()], + data: [getAlertMock(getQueryRuleParams())], }, [] ); @@ -319,7 +296,7 @@ describe('utils', () => { describe('transform', () => { test('outputs 200 if the data is of type siem alert', () => { - const output = transform(getResult()); + const output = transform(getAlertMock(getQueryRuleParams())); const expected = getOutputRuleAlertForRest(); expect(output).toEqual(expected); }); @@ -434,7 +411,7 @@ describe('utils', () => { describe('transformOrBulkError', () => { test('outputs 200 if the data is of type siem alert', () => { - const output = transformOrBulkError('rule-1', getResult(), { + const output = transformOrBulkError('rule-1', getAlertMock(getQueryRuleParams()), { id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', actions: [], ruleThrottle: 'no_actions', @@ -466,15 +443,15 @@ describe('utils', () => { }); test('given single alert will return the alert transformed', () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); const transformed = transformAlertsToRules([result1]); const expected = getOutputRuleAlertForRest(); expect(transformed).toEqual([expected]); }); test('given two alerts will return the two alerts transformed', () => { - const result1 = getResult(); - const result2 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = 'some other id'; result2.params.ruleId = 'some other id'; @@ -489,7 +466,7 @@ describe('utils', () => { describe('transformOrImportError', () => { test('returns 1 given success if the alert is an alert type and the existing success count is 0', () => { - const output = transformOrImportError('rule-1', getResult(), { + const output = transformOrImportError('rule-1', getAlertMock(getQueryRuleParams()), { success: true, success_count: 0, errors: [], @@ -503,7 +480,7 @@ describe('utils', () => { }); test('returns 2 given successes if the alert is an alert type and the existing success count is 1', () => { - const output = transformOrImportError('rule-1', getResult(), { + const output = transformOrImportError('rule-1', getAlertMock(getQueryRuleParams()), { success: true, success_count: 1, errors: [], 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 a28cc9bcb9b698..466b8dd1842276 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 @@ -5,7 +5,7 @@ * 2.0. */ -import { pickBy, countBy } from 'lodash/fp'; +import { countBy } from 'lodash/fp'; import { SavedObject, SavedObjectsFindResponse } from 'kibana/server'; import uuid from 'uuid'; @@ -32,7 +32,8 @@ import { OutputError, } from '../utils'; import { RuleActions } from '../../rule_actions/types'; -import { RuleTypeParams } from '../../types'; +import { internalRuleToAPIResponse } from '../../schemas/rule_converters'; +import { RuleParams } from '../../schemas/rule_schemas'; type PromiseFromStreams = ImportRulesSchemaDecoded | Error; @@ -106,68 +107,7 @@ export const transformAlertToRule = ( ruleActions?: RuleActions | null, ruleStatus?: SavedObject ): Partial => { - return pickBy((value: unknown) => value != null, { - author: alert.params.author ?? [], - actions: ruleActions?.actions ?? [], - building_block_type: alert.params.buildingBlockType, - created_at: alert.createdAt.toISOString(), - updated_at: alert.updatedAt.toISOString(), - created_by: alert.createdBy ?? 'elastic', - description: alert.params.description, - enabled: alert.enabled, - anomaly_threshold: alert.params.anomalyThreshold, - event_category_override: alert.params.eventCategoryOverride, - false_positives: alert.params.falsePositives, - filters: alert.params.filters, - from: alert.params.from, - id: alert.id, - immutable: alert.params.immutable, - index: alert.params.index, - interval: alert.schedule.interval, - rule_id: alert.params.ruleId, - language: alert.params.language, - license: alert.params.license, - output_index: alert.params.outputIndex, - max_signals: alert.params.maxSignals, - machine_learning_job_id: alert.params.machineLearningJobId, - risk_score: alert.params.riskScore, - risk_score_mapping: alert.params.riskScoreMapping ?? [], - rule_name_override: alert.params.ruleNameOverride, - name: alert.name, - query: alert.params.query, - references: alert.params.references, - saved_id: alert.params.savedId, - timeline_id: alert.params.timelineId, - timeline_title: alert.params.timelineTitle, - meta: alert.params.meta, - severity: alert.params.severity, - severity_mapping: alert.params.severityMapping ?? [], - updated_by: alert.updatedBy ?? 'elastic', - tags: transformTags(alert.tags), - to: alert.params.to, - type: alert.params.type, - threat: alert.params.threat ?? [], - threshold: alert.params.threshold, - threat_filters: alert.params.threatFilters, - threat_index: alert.params.threatIndex, - threat_indicator_path: alert.params.threatIndicatorPath, - 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, - version: alert.params.version, - status: ruleStatus?.attributes.status ?? undefined, - status_date: ruleStatus?.attributes.statusDate, - last_failure_at: ruleStatus?.attributes.lastFailureAt ?? undefined, - last_success_at: ruleStatus?.attributes.lastSuccessAt ?? undefined, - last_failure_message: ruleStatus?.attributes.lastFailureMessage ?? undefined, - last_success_message: ruleStatus?.attributes.lastSuccessMessage ?? undefined, - exceptions_list: alert.params.exceptionsList ?? [], - }); + return internalRuleToAPIResponse(alert, ruleActions, ruleStatus); }; export const transformAlertsToRules = (alerts: RuleAlertType[]): Array> => { @@ -175,7 +115,7 @@ export const transformAlertsToRules = (alerts: RuleAlertType[]): Array, + findResults: FindResult, ruleActions: Array, ruleStatuses?: Array> ): { @@ -206,7 +146,7 @@ export const transformFindAlerts = ( }; export const transform = ( - alert: PartialAlert, + alert: PartialAlert, ruleActions?: RuleActions | null, ruleStatus?: SavedObject ): Partial | null => { @@ -223,7 +163,7 @@ export const transform = ( export const transformOrBulkError = ( ruleId: string, - alert: PartialAlert, + alert: PartialAlert, ruleActions: RuleActions, ruleStatus?: unknown ): Partial | BulkError => { @@ -244,7 +184,7 @@ export const transformOrBulkError = ( export const transformOrImportError = ( ruleId: string, - alert: PartialAlert, + alert: PartialAlert, existingImportSuccessError: ImportSuccessError ): ImportSuccessError => { if (isAlertType(alert)) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.test.ts index 5bb63ada7f9a4c..f971a5606f6c65 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.test.ts @@ -5,22 +5,18 @@ * 2.0. */ -import { - transformValidate, - transformValidateFindAlerts, - transformValidateBulkError, -} from './validate'; -import { FindResult } from '../../../../../../alerting/server'; +import { transformValidate, transformValidateBulkError } from './validate'; import { BulkError } from '../utils'; import { RulesSchema } from '../../../../../common/detection_engine/schemas/response'; -import { getResult, getFindResultStatus } from '../__mocks__/request_responses'; +import { getAlertMock, getFindResultStatus } from '../__mocks__/request_responses'; import { getListArrayMock } from '../../../../../common/detection_engine/schemas/types/lists.mock'; import { getThreatMock } from '../../../../../common/detection_engine/schemas/types/threat.mock'; -import { RuleTypeParams } from '../../types'; +import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; export const ruleOutput = (): RulesSchema => ({ actions: [], author: ['Elastic'], + building_block_type: 'default', created_at: '2019-12-13T16:40:33.400Z', updated_at: '2019-12-13T16:40:33.400Z', created_by: 'elastic', @@ -35,12 +31,12 @@ export const ruleOutput = (): RulesSchema => ({ language: 'kuery', license: 'Elastic License', output_index: '.siem-signals', - max_signals: 100, + max_signals: 10000, risk_score: 50, risk_score_mapping: [], name: 'Detect Root/Admin Users', query: 'user.name: root or user.name: admin', - references: ['http://www.example.com', 'https://ww.example.com'], + references: ['http://example.com', 'https://example.com'], severity: 'high', severity_mapping: [], updated_by: 'elastic', @@ -72,14 +68,14 @@ export const ruleOutput = (): RulesSchema => ({ describe('validate', () => { describe('transformValidate', () => { test('it should do a validation correctly of a partial alert', () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); const [validated, errors] = transformValidate(ruleAlert); expect(validated).toEqual(ruleOutput()); expect(errors).toEqual(null); }); test('it should do an in-validation correctly of a partial alert', () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); // @ts-expect-error delete ruleAlert.name; const [validated, errors] = transformValidate(ruleAlert); @@ -88,54 +84,15 @@ describe('validate', () => { }); }); - describe('transformValidateFindAlerts', () => { - test('it should do a validation correctly of a find alert', () => { - const findResult: FindResult = { - data: [getResult()], - page: 1, - perPage: 0, - total: 0, - }; - const [validated, errors] = transformValidateFindAlerts(findResult, []); - const expected: { - page: number; - perPage: number; - total: number; - data: Array>; - } | null = { - data: [ruleOutput()], - page: 1, - perPage: 0, - total: 0, - }; - expect(validated).toEqual(expected); - expect(errors).toEqual(null); - }); - - test('it should do an in-validation correctly of a partial alert', () => { - const findResult: FindResult = { - data: [getResult()], - page: 1, - perPage: 0, - total: 0, - }; - // @ts-expect-error - delete findResult.page; - const [validated, errors] = transformValidateFindAlerts(findResult, []); - expect(validated).toEqual(null); - expect(errors).toEqual('Invalid value "undefined" supplied to "page"'); - }); - }); - describe('transformValidateBulkError', () => { test('it should do a validation correctly of a rule id', () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); const validatedOrError = transformValidateBulkError('rule-1', ruleAlert); expect(validatedOrError).toEqual(ruleOutput()); }); test('it should do an in-validation correctly of a rule id', () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); // @ts-expect-error delete ruleAlert.name; const validatedOrError = transformValidateBulkError('rule-1', ruleAlert); @@ -151,7 +108,7 @@ describe('validate', () => { test('it should do a validation correctly of a rule id with ruleStatus passed in', () => { const ruleStatus = getFindResultStatus(); - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); const validatedOrError = transformValidateBulkError('rule-1', ruleAlert, null, ruleStatus); const expected: RulesSchema = { ...ruleOutput(), @@ -164,7 +121,7 @@ describe('validate', () => { }); test('it should return error object if "alert" is not expected alert type', () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); // @ts-expect-error delete ruleAlert.alertTypeId; const validatedOrError = transformValidateBulkError('rule-1', ruleAlert); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.ts index cff7413308a4c5..ac9ac960d6f065 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.ts @@ -6,23 +6,17 @@ */ import { SavedObject, SavedObjectsFindResponse } from 'kibana/server'; -import { fold } from 'fp-ts/lib/Either'; -import { pipe } from 'fp-ts/lib/pipeable'; -import * as t from 'io-ts'; import { FullResponseSchema, fullResponseSchema, } from '../../../../../common/detection_engine/schemas/request'; -import { validate } from '../../../../../common/validate'; -import { findRulesSchema } from '../../../../../common/detection_engine/schemas/response/find_rules_schema'; +import { validateNonExact } from '../../../../../common/validate'; import { RulesSchema, rulesSchema, } from '../../../../../common/detection_engine/schemas/response/rules_schema'; -import { formatErrors } from '../../../../../common/format_errors'; -import { exactCheck } from '../../../../../common/exact_check'; -import { PartialAlert, FindResult } from '../../../../../../alerting/server'; +import { PartialAlert } from '../../../../../../alerting/server'; import { isAlertType, IRuleSavedAttributesSavedObjectAttributes, @@ -30,42 +24,12 @@ import { IRuleStatusSOAttributes, } from '../../rules/types'; import { createBulkErrorObject, BulkError } from '../utils'; -import { transformFindAlerts, transform, transformAlertToRule } from './utils'; +import { transform, transformAlertToRule } from './utils'; import { RuleActions } from '../../rule_actions/types'; -import { RuleTypeParams } from '../../types'; - -export const transformValidateFindAlerts = ( - findResults: FindResult, - ruleActions: Array, - ruleStatuses?: Array> -): [ - { - page: number; - perPage: number; - total: number; - data: Array>; - } | null, - string | null -] => { - const transformed = transformFindAlerts(findResults, ruleActions, ruleStatuses); - if (transformed == null) { - return [null, 'Internal error transforming']; - } else { - const decoded = findRulesSchema.decode(transformed); - const checked = exactCheck(transformed, decoded); - const left = (errors: t.Errors): string[] => formatErrors(errors); - const right = (): string[] => []; - const piped = pipe(checked, fold(left, right)); - if (piped.length === 0) { - return [transformed, null]; - } else { - return [null, piped.join(',')]; - } - } -}; +import { RuleParams } from '../../schemas/rule_schemas'; export const transformValidate = ( - alert: PartialAlert, + alert: PartialAlert, ruleActions?: RuleActions | null, ruleStatus?: SavedObject ): [RulesSchema | null, string | null] => { @@ -73,12 +37,12 @@ export const transformValidate = ( if (transformed == null) { return [null, 'Internal error transforming']; } else { - return validate(transformed, rulesSchema); + return validateNonExact(transformed, rulesSchema); } }; export const newTransformValidate = ( - alert: PartialAlert, + alert: PartialAlert, ruleActions?: RuleActions | null, ruleStatus?: SavedObject ): [FullResponseSchema | null, string | null] => { @@ -86,13 +50,13 @@ export const newTransformValidate = ( if (transformed == null) { return [null, 'Internal error transforming']; } else { - return validate(transformed, fullResponseSchema); + return validateNonExact(transformed, fullResponseSchema); } }; export const transformValidateBulkError = ( ruleId: string, - alert: PartialAlert, + alert: PartialAlert, ruleActions?: RuleActions | null, ruleStatus?: SavedObjectsFindResponse ): RulesSchema | BulkError => { @@ -103,7 +67,7 @@ export const transformValidateBulkError = ( ruleActions, ruleStatus?.saved_objects[0] ?? ruleStatus ); - const [validated, errors] = validate(transformed, rulesSchema); + const [validated, errors] = validateNonExact(transformed, rulesSchema); if (errors != null || validated == null) { return createBulkErrorObject({ ruleId, @@ -115,7 +79,7 @@ export const transformValidateBulkError = ( } } else { const transformed = transformAlertToRule(alert); - const [validated, errors] = validate(transformed, rulesSchema); + const [validated, errors] = validateNonExact(transformed, rulesSchema); if (errors != null || validated == null) { return createBulkErrorObject({ ruleId, 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 43fba889c04d51..cca7e871f5b8b6 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 @@ -28,8 +28,9 @@ import { } from './utils'; import { responseMock } from './__mocks__'; import { exampleRuleStatus, exampleFindRuleStatusResponse } from '../signals/__mocks__/es_results'; -import { getResult } from './__mocks__/request_responses'; +import { getAlertMock } from './__mocks__/request_responses'; import { AlertExecutionStatusErrorReasons } from '../../../../../alerting/common'; +import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; let alertsClient: ReturnType; @@ -479,12 +480,12 @@ describe('utils', () => { alertsClient = alertsClientMock.create(); }); it('getFailingRules finds no failing rules', async () => { - alertsClient.get.mockResolvedValue(getResult()); + alertsClient.get.mockResolvedValue(getAlertMock(getQueryRuleParams())); const res = await getFailingRules(['my-fake-id'], alertsClient); expect(res).toEqual({}); }); it('getFailingRules finds a failing rule', async () => { - const foundRule = getResult(); + const foundRule = getAlertMock(getQueryRuleParams()); foundRule.executionStatus = { status: 'error', lastExecutionDate: foundRule.executionStatus.lastExecutionDate, 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 a654dd6a10e329..2a3d83f4baca78 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 @@ -5,6 +5,7 @@ * 2.0. */ +import { normalizeThresholdObject } from '../../../../common/detection_engine/utils'; import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; import { SanitizedAlert } from '../../../../../alerting/common'; import { SERVER_APP_ID, SIGNALS_ID } from '../../../../common/constants'; @@ -97,7 +98,7 @@ export const createRules = async ({ severity, severityMapping, threat, - threshold, + threshold: threshold ? normalizeThresholdObject(threshold) : undefined, /** * TODO: Fix typing inconsistancy between `RuleTypeParams` and `CreateRulesOptions` */ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/find_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/find_rules.ts index 26151745b00d9f..754aaf67c32243 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/find_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/find_rules.ts @@ -7,7 +7,7 @@ import { FindResult } from '../../../../../alerting/server'; import { SIGNALS_ID } from '../../../../common/constants'; -import { RuleTypeParams } from '../types'; +import { RuleParams } from '../schemas/rule_schemas'; import { FindRuleOptions } from './types'; export const getFilter = (filter: string | null | undefined) => { @@ -26,7 +26,7 @@ export const findRules = async ({ filter, sortField, sortOrder, -}: FindRuleOptions): Promise> => { +}: FindRuleOptions): Promise> => { return alertsClient.find({ options: { fields, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_existing_prepackaged_rules.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_existing_prepackaged_rules.test.ts index ead4fac8113728..da67bea0ca9705 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_existing_prepackaged_rules.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_existing_prepackaged_rules.test.ts @@ -7,10 +7,11 @@ import { alertsClientMock } from '../../../../../alerting/server/mocks'; import { - getResult, + getAlertMock, getFindResultWithSingleHit, getFindResultWithMultiHits, } from '../routes/__mocks__/request_responses'; +import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; import { getExistingPrepackagedRules, getNonPackagedRules, @@ -29,21 +30,21 @@ describe('get_existing_prepackaged_rules', () => { const alertsClient = alertsClientMock.create(); alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); const rules = await getExistingPrepackagedRules({ alertsClient }); - expect(rules).toEqual([getResult()]); + expect(rules).toEqual([getAlertMock(getQueryRuleParams())]); }); test('should return 3 items over 1 page with all on one page', async () => { const alertsClient = alertsClientMock.create(); - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.params.immutable = true; result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.params.immutable = true; result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; - const result3 = getResult(); + const result3 = getAlertMock(getQueryRuleParams()); result3.params.immutable = true; result3.id = 'f3e1bf0b-b95f-43da-b1de-5d2f0af2287a'; @@ -77,16 +78,16 @@ describe('get_existing_prepackaged_rules', () => { const alertsClient = alertsClientMock.create(); alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); const rules = await getNonPackagedRules({ alertsClient }); - expect(rules).toEqual([getResult()]); + expect(rules).toEqual([getAlertMock(getQueryRuleParams())]); }); test('should return 2 items over 1 page', async () => { const alertsClient = alertsClientMock.create(); - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; // first result mock which is for returning the total @@ -111,13 +112,13 @@ describe('get_existing_prepackaged_rules', () => { test('should return 3 items over 1 page with all on one page', async () => { const alertsClient = alertsClientMock.create(); - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; - const result3 = getResult(); + const result3 = getAlertMock(getQueryRuleParams()); result3.id = 'f3e1bf0b-b95f-43da-b1de-5d2f0af2287a'; // first result mock which is for returning the total @@ -150,16 +151,16 @@ describe('get_existing_prepackaged_rules', () => { const alertsClient = alertsClientMock.create(); alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); const rules = await getRules({ alertsClient, filter: '' }); - expect(rules).toEqual([getResult()]); + expect(rules).toEqual([getAlertMock(getQueryRuleParams())]); }); test('should return 2 items over two pages, one per page', async () => { const alertsClient = alertsClientMock.create(); - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; // first result mock which is for returning the total diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts index 8ead079c9502e1..4c937b2e4ca8aa 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts @@ -6,7 +6,7 @@ */ import { - getResult, + getAlertMock, getFindResultWithSingleHit, FindHit, } from '../routes/__mocks__/request_responses'; @@ -14,60 +14,72 @@ import { alertsClientMock } from '../../../../../alerting/server/mocks'; import { getExportAll } from './get_export_all'; import { getListArrayMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; import { getThreatMock } from '../../../../common/detection_engine/schemas/types/threat.mock'; +import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; describe('getExportAll', () => { test('it exports everything from the alerts client', async () => { const alertsClient = alertsClientMock.create(); - alertsClient.get.mockResolvedValue(getResult()); - alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + const result = getFindResultWithSingleHit(); + const alert = getAlertMock(getQueryRuleParams()); + alert.params = { + ...alert.params, + filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }], + threat: getThreatMock(), + meta: { someMeta: 'someField' }, + timelineId: 'some-timeline-id', + timelineTitle: 'some-timeline-title', + }; + result.data = [alert]; + alertsClient.find.mockResolvedValue(result); const exports = await getExportAll(alertsClient); - expect(exports).toEqual({ - rulesNdjson: `${JSON.stringify({ - author: ['Elastic'], - actions: [], - created_at: '2019-12-13T16:40:33.400Z', - updated_at: '2019-12-13T16:40:33.400Z', - created_by: 'elastic', - description: 'Detecting root and admin users', - enabled: true, - false_positives: [], - filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }], - from: 'now-6m', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - rule_id: 'rule-1', - language: 'kuery', - license: 'Elastic License', - output_index: '.siem-signals', - max_signals: 100, - risk_score: 50, - risk_score_mapping: [], - name: 'Detect Root/Admin Users', - query: 'user.name: root or user.name: admin', - references: ['http://www.example.com', 'https://ww.example.com'], - timeline_id: 'some-timeline-id', - timeline_title: 'some-timeline-title', - meta: { someMeta: 'someField' }, - severity: 'high', - severity_mapping: [], - updated_by: 'elastic', - tags: [], - to: 'now', - type: 'query', - threat: getThreatMock(), - throttle: 'no_actions', - note: '# Investigative notes', - version: 1, - exceptions_list: getListArrayMock(), - })}\n`, - exportDetails: `${JSON.stringify({ - exported_count: 1, - missing_rules: [], - missing_rules_count: 0, - })}\n`, + const rulesJson = JSON.parse(exports.rulesNdjson); + const detailsJson = JSON.parse(exports.exportDetails); + expect(rulesJson).toEqual({ + author: ['Elastic'], + actions: [], + building_block_type: 'default', + created_at: '2019-12-13T16:40:33.400Z', + updated_at: '2019-12-13T16:40:33.400Z', + created_by: 'elastic', + description: 'Detecting root and admin users', + enabled: true, + false_positives: [], + filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }], + from: 'now-6m', + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + immutable: false, + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + rule_id: 'rule-1', + language: 'kuery', + license: 'Elastic License', + output_index: '.siem-signals', + max_signals: 10000, + risk_score: 50, + risk_score_mapping: [], + name: 'Detect Root/Admin Users', + query: 'user.name: root or user.name: admin', + references: ['http://example.com', 'https://example.com'], + timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', + meta: { someMeta: 'someField' }, + severity: 'high', + severity_mapping: [], + updated_by: 'elastic', + tags: [], + to: 'now', + type: 'query', + threat: getThreatMock(), + throttle: 'no_actions', + note: '# Investigative notes', + version: 1, + exceptions_list: getListArrayMock(), + }); + expect(detailsJson).toEqual({ + exported_count: 1, + missing_rules: [], + missing_rules_count: 0, }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts index 537a45115e83e9..b14b805a31fc33 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts @@ -7,7 +7,7 @@ import { getExportByObjectIds, getRulesFromObjects, RulesErrors } from './get_export_by_object_ids'; import { - getResult, + getAlertMock, getFindResultWithSingleHit, FindHit, } from '../routes/__mocks__/request_responses'; @@ -15,6 +15,7 @@ import * as readRules from './read_rules'; import { alertsClientMock } from '../../../../../alerting/server/mocks'; import { getListArrayMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; import { getThreatMock } from '../../../../common/detection_engine/schemas/types/threat.mock'; +import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; describe('get_export_by_object_ids', () => { beforeEach(() => { @@ -25,15 +26,19 @@ describe('get_export_by_object_ids', () => { describe('getExportByObjectIds', () => { test('it exports object ids into an expected string with new line characters', async () => { const alertsClient = alertsClientMock.create(); - alertsClient.get.mockResolvedValue(getResult()); alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); const objects = [{ rule_id: 'rule-1' }]; const exports = await getExportByObjectIds(alertsClient, objects); - expect(exports).toEqual({ - rulesNdjson: `${JSON.stringify({ + const exportsObj = { + rulesNdjson: JSON.parse(exports.rulesNdjson), + exportDetails: JSON.parse(exports.exportDetails), + }; + expect(exportsObj).toEqual({ + rulesNdjson: { author: ['Elastic'], actions: [], + building_block_type: 'default', created_at: '2019-12-13T16:40:33.400Z', updated_at: '2019-12-13T16:40:33.400Z', created_by: 'elastic', @@ -50,12 +55,12 @@ describe('get_export_by_object_ids', () => { language: 'kuery', license: 'Elastic License', output_index: '.siem-signals', - max_signals: 100, + max_signals: 10000, risk_score: 50, risk_score_mapping: [], name: 'Detect Root/Admin Users', query: 'user.name: root or user.name: admin', - references: ['http://www.example.com', 'https://ww.example.com'], + references: ['http://example.com', 'https://example.com'], timeline_id: 'some-timeline-id', timeline_title: 'some-timeline-title', meta: { someMeta: 'someField' }, @@ -70,18 +75,18 @@ describe('get_export_by_object_ids', () => { note: '# Investigative notes', version: 1, exceptions_list: getListArrayMock(), - })}\n`, - exportDetails: `${JSON.stringify({ + }, + exportDetails: { exported_count: 1, missing_rules: [], missing_rules_count: 0, - })}\n`, + }, }); }); test('it does not export immutable rules', async () => { const alertsClient = alertsClientMock.create(); - const result = getResult(); + const result = getAlertMock(getQueryRuleParams()); result.params.immutable = true; const findResult: FindHit = { @@ -91,7 +96,7 @@ describe('get_export_by_object_ids', () => { data: [result], }; - alertsClient.get.mockResolvedValue(getResult()); + alertsClient.get.mockResolvedValue(getAlertMock(getQueryRuleParams())); alertsClient.find.mockResolvedValue(findResult); const objects = [{ rule_id: 'rule-1' }]; @@ -107,7 +112,6 @@ describe('get_export_by_object_ids', () => { describe('getRulesFromObjects', () => { test('it returns transformed rules from objects sent in', async () => { const alertsClient = alertsClientMock.create(); - alertsClient.get.mockResolvedValue(getResult()); alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); const objects = [{ rule_id: 'rule-1' }]; @@ -119,6 +123,7 @@ describe('get_export_by_object_ids', () => { { actions: [], author: ['Elastic'], + building_block_type: 'default', created_at: '2019-12-13T16:40:33.400Z', updated_at: '2019-12-13T16:40:33.400Z', created_by: 'elastic', @@ -133,14 +138,22 @@ describe('get_export_by_object_ids', () => { interval: '5m', rule_id: 'rule-1', language: 'kuery', + last_failure_at: undefined, + last_failure_message: undefined, + last_success_at: undefined, + last_success_message: undefined, license: 'Elastic License', output_index: '.siem-signals', - max_signals: 100, + max_signals: 10000, risk_score: 50, risk_score_mapping: [], + rule_name_override: undefined, + saved_id: undefined, + status: undefined, + status_date: undefined, name: 'Detect Root/Admin Users', query: 'user.name: root or user.name: admin', - references: ['http://www.example.com', 'https://ww.example.com'], + references: ['http://example.com', 'https://example.com'], timeline_id: 'some-timeline-id', timeline_title: 'some-timeline-title', meta: { someMeta: 'someField' }, @@ -163,7 +176,7 @@ describe('get_export_by_object_ids', () => { test('it returns error when readRules throws error', async () => { const alertsClient = alertsClientMock.create(); - alertsClient.get.mockResolvedValue(getResult()); + alertsClient.get.mockResolvedValue(getAlertMock(getQueryRuleParams())); alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); jest.spyOn(readRules, 'readRules').mockImplementation(async () => { throw new Error('Test error'); @@ -180,7 +193,7 @@ describe('get_export_by_object_ids', () => { test('it does not transform the rule if the rule is an immutable rule and designates it as a missing rule', async () => { const alertsClient = alertsClientMock.create(); - const result = getResult(); + const result = getAlertMock(getQueryRuleParams()); result.params.immutable = true; const findResult: FindHit = { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_rules_to_install.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_rules_to_install.test.ts index 53da230d70c569..7482097aafd228 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_rules_to_install.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_rules_to_install.test.ts @@ -6,8 +6,9 @@ */ import { getRulesToInstall } from './get_rules_to_install'; -import { getResult } from '../routes/__mocks__/request_responses'; +import { getAlertMock } from '../routes/__mocks__/request_responses'; import { getAddPrepackagedRulesSchemaDecodedMock } from '../../../../common/detection_engine/schemas/request/add_prepackaged_rules_schema.mock'; +import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; describe('get_rules_to_install', () => { test('should return empty array if both rule sets are empty', () => { @@ -19,7 +20,7 @@ describe('get_rules_to_install', () => { const ruleFromFileSystem = getAddPrepackagedRulesSchemaDecodedMock(); ruleFromFileSystem.rule_id = 'rule-1'; - const installedRule = getResult(); + const installedRule = getAlertMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-1'; const update = getRulesToInstall([ruleFromFileSystem], [installedRule]); expect(update).toEqual([]); @@ -29,7 +30,7 @@ describe('get_rules_to_install', () => { const ruleFromFileSystem = getAddPrepackagedRulesSchemaDecodedMock(); ruleFromFileSystem.rule_id = 'rule-1'; - const installedRule = getResult(); + const installedRule = getAlertMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-2'; const update = getRulesToInstall([ruleFromFileSystem], [installedRule]); expect(update).toEqual([ruleFromFileSystem]); @@ -42,7 +43,7 @@ describe('get_rules_to_install', () => { const ruleFromFileSystem2 = getAddPrepackagedRulesSchemaDecodedMock(); ruleFromFileSystem2.rule_id = 'rule-2'; - const installedRule = getResult(); + const installedRule = getAlertMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-3'; const update = getRulesToInstall([ruleFromFileSystem1, ruleFromFileSystem2], [installedRule]); expect(update).toEqual([ruleFromFileSystem1, ruleFromFileSystem2]); @@ -58,7 +59,7 @@ describe('get_rules_to_install', () => { const ruleFromFileSystem3 = getAddPrepackagedRulesSchemaDecodedMock(); ruleFromFileSystem3.rule_id = 'rule-3'; - const installedRule = getResult(); + const installedRule = getAlertMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-3'; const update = getRulesToInstall( [ruleFromFileSystem1, ruleFromFileSystem2, ruleFromFileSystem3], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_rules_to_update.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_rules_to_update.test.ts index dfcfc6c41c3c04..163585e7594abe 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_rules_to_update.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_rules_to_update.test.ts @@ -6,8 +6,9 @@ */ import { filterInstalledRules, getRulesToUpdate, mergeExceptionLists } from './get_rules_to_update'; -import { getResult } from '../routes/__mocks__/request_responses'; +import { getAlertMock } from '../routes/__mocks__/request_responses'; import { getAddPrepackagedRulesSchemaDecodedMock } from '../../../../common/detection_engine/schemas/request/add_prepackaged_rules_schema.mock'; +import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; describe('get_rules_to_update', () => { describe('get_rules_to_update', () => { @@ -21,7 +22,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem.rule_id = 'rule-1'; ruleFromFileSystem.version = 2; - const installedRule = getResult(); + const installedRule = getAlertMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-2'; installedRule.params.version = 1; const update = getRulesToUpdate([ruleFromFileSystem], [installedRule]); @@ -33,7 +34,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem.rule_id = 'rule-1'; ruleFromFileSystem.version = 1; - const installedRule = getResult(); + const installedRule = getAlertMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-1'; installedRule.params.version = 2; const update = getRulesToUpdate([ruleFromFileSystem], [installedRule]); @@ -45,7 +46,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem.rule_id = 'rule-1'; ruleFromFileSystem.version = 1; - const installedRule = getResult(); + const installedRule = getAlertMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-1'; installedRule.params.version = 1; const update = getRulesToUpdate([ruleFromFileSystem], [installedRule]); @@ -57,7 +58,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem.rule_id = 'rule-1'; ruleFromFileSystem.version = 2; - const installedRule = getResult(); + const installedRule = getAlertMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-1'; installedRule.params.version = 1; installedRule.params.exceptionsList = []; @@ -71,12 +72,12 @@ describe('get_rules_to_update', () => { ruleFromFileSystem.rule_id = 'rule-1'; ruleFromFileSystem.version = 2; - const installedRule1 = getResult(); + const installedRule1 = getAlertMock(getQueryRuleParams()); installedRule1.params.ruleId = 'rule-1'; installedRule1.params.version = 1; installedRule1.params.exceptionsList = []; - const installedRule2 = getResult(); + const installedRule2 = getAlertMock(getQueryRuleParams()); installedRule2.params.ruleId = 'rule-2'; installedRule2.params.version = 1; installedRule2.params.exceptionsList = []; @@ -94,12 +95,12 @@ describe('get_rules_to_update', () => { ruleFromFileSystem2.rule_id = 'rule-2'; ruleFromFileSystem2.version = 2; - const installedRule1 = getResult(); + const installedRule1 = getAlertMock(getQueryRuleParams()); installedRule1.params.ruleId = 'rule-1'; installedRule1.params.version = 1; installedRule1.params.exceptionsList = []; - const installedRule2 = getResult(); + const installedRule2 = getAlertMock(getQueryRuleParams()); installedRule2.params.ruleId = 'rule-2'; installedRule2.params.version = 1; installedRule2.params.exceptionsList = []; @@ -124,7 +125,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem1.rule_id = 'rule-1'; ruleFromFileSystem1.version = 2; - const installedRule1 = getResult(); + const installedRule1 = getAlertMock(getQueryRuleParams()); installedRule1.params.ruleId = 'rule-1'; installedRule1.params.version = 1; installedRule1.params.exceptionsList = []; @@ -146,7 +147,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem1.rule_id = 'rule-1'; ruleFromFileSystem1.version = 2; - const installedRule1 = getResult(); + const installedRule1 = getAlertMock(getQueryRuleParams()); installedRule1.params.ruleId = 'rule-1'; installedRule1.params.version = 1; installedRule1.params.exceptionsList = [ @@ -178,7 +179,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem1.rule_id = 'rule-1'; ruleFromFileSystem1.version = 2; - const installedRule1 = getResult(); + const installedRule1 = getAlertMock(getQueryRuleParams()); installedRule1.params.ruleId = 'rule-1'; installedRule1.params.version = 1; installedRule1.params.exceptionsList = [ @@ -200,7 +201,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem1.rule_id = 'rule-1'; ruleFromFileSystem1.version = 2; - const installedRule1 = getResult(); + const installedRule1 = getAlertMock(getQueryRuleParams()); installedRule1.params.ruleId = 'rule-1'; installedRule1.params.version = 1; installedRule1.params.exceptionsList = [ @@ -227,7 +228,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem2.rule_id = 'rule-2'; ruleFromFileSystem2.version = 2; - const installedRule1 = getResult(); + const installedRule1 = getAlertMock(getQueryRuleParams()); installedRule1.params.ruleId = 'rule-1'; installedRule1.params.version = 1; installedRule1.params.exceptionsList = [ @@ -238,7 +239,7 @@ describe('get_rules_to_update', () => { type: 'endpoint', }, ]; - const installedRule2 = getResult(); + const installedRule2 = getAlertMock(getQueryRuleParams()); installedRule2.params.ruleId = 'rule-2'; installedRule2.params.version = 1; installedRule2.params.exceptionsList = [ @@ -277,7 +278,7 @@ describe('get_rules_to_update', () => { }, ]; - const installedRule1 = getResult(); + const installedRule1 = getAlertMock(getQueryRuleParams()); installedRule1.params.ruleId = 'rule-1'; installedRule1.params.version = 1; installedRule1.params.exceptionsList = [ @@ -289,7 +290,7 @@ describe('get_rules_to_update', () => { }, ]; - const installedRule2 = getResult(); + const installedRule2 = getAlertMock(getQueryRuleParams()); installedRule2.params.ruleId = 'rule-2'; installedRule2.params.version = 1; installedRule2.params.exceptionsList = [ @@ -319,7 +320,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem.rule_id = 'rule-1'; ruleFromFileSystem.version = 2; - const installedRule = getResult(); + const installedRule = getAlertMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-2'; installedRule.params.version = 1; const shouldUpdate = filterInstalledRules(ruleFromFileSystem, [installedRule]); @@ -331,7 +332,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem.rule_id = 'rule-1'; ruleFromFileSystem.version = 1; - const installedRule = getResult(); + const installedRule = getAlertMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-1'; installedRule.params.version = 2; const shouldUpdate = filterInstalledRules(ruleFromFileSystem, [installedRule]); @@ -343,7 +344,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem.rule_id = 'rule-1'; ruleFromFileSystem.version = 1; - const installedRule = getResult(); + const installedRule = getAlertMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-1'; installedRule.params.version = 1; const shouldUpdate = filterInstalledRules(ruleFromFileSystem, [installedRule]); @@ -355,7 +356,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem.rule_id = 'rule-1'; ruleFromFileSystem.version = 2; - const installedRule = getResult(); + const installedRule = getAlertMock(getQueryRuleParams()); installedRule.params.ruleId = 'rule-1'; installedRule.params.version = 1; installedRule.params.exceptionsList = []; @@ -379,7 +380,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem1.rule_id = 'rule-1'; ruleFromFileSystem1.version = 2; - const installedRule1 = getResult(); + const installedRule1 = getAlertMock(getQueryRuleParams()); installedRule1.params.ruleId = 'rule-1'; installedRule1.params.version = 1; installedRule1.params.exceptionsList = []; @@ -401,7 +402,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem1.rule_id = 'rule-1'; ruleFromFileSystem1.version = 2; - const installedRule1 = getResult(); + const installedRule1 = getAlertMock(getQueryRuleParams()); installedRule1.params.ruleId = 'rule-1'; installedRule1.params.version = 1; installedRule1.params.exceptionsList = [ @@ -433,7 +434,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem1.rule_id = 'rule-1'; ruleFromFileSystem1.version = 2; - const installedRule1 = getResult(); + const installedRule1 = getAlertMock(getQueryRuleParams()); installedRule1.params.ruleId = 'rule-1'; installedRule1.params.version = 1; installedRule1.params.exceptionsList = [ @@ -455,7 +456,7 @@ describe('get_rules_to_update', () => { ruleFromFileSystem1.rule_id = 'rule-1'; ruleFromFileSystem1.version = 2; - const installedRule1 = getResult(); + const installedRule1 = getAlertMock(getQueryRuleParams()); installedRule1.params.ruleId = 'rule-1'; installedRule1.params.version = 1; installedRule1.params.exceptionsList = [ 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 796496e20809cc..d42b6c5aeefaae 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 @@ -8,143 +8,8 @@ import { PatchRulesOptions } from './types'; import { alertsClientMock } from '../../../../../alerting/server/mocks'; import { savedObjectsClientMock } from '../../../../../../../src/core/server/mocks'; -import { INTERNAL_RULE_ID_KEY, INTERNAL_IMMUTABLE_KEY } from '../../../../common/constants'; -import { SanitizedAlert } from '../../../../../alerting/common'; -import { RuleTypeParams } from '../types'; - -const rule: SanitizedAlert = { - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - name: 'Detect Root/Admin Users', - tags: [`${INTERNAL_RULE_ID_KEY}:rule-1`, `${INTERNAL_IMMUTABLE_KEY}:false`], - alertTypeId: 'siem.signals', - consumer: 'siem', - params: { - anomalyThreshold: undefined, - description: 'Detecting root and admin users', - ruleId: 'rule-1', - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - falsePositives: [], - from: 'now-6m', - immutable: false, - query: 'user.name: root or user.name: admin', - language: 'kuery', - machineLearningJobId: undefined, - outputIndex: '.siem-signals', - timelineId: 'some-timeline-id', - timelineTitle: 'some-timeline-title', - meta: { someMeta: 'someField' }, - filters: [ - { - query: { - match_phrase: { - 'host.name': 'some-host', - }, - }, - }, - ], - riskScore: 50, - maxSignals: 100, - severity: 'high', - to: 'now', - type: 'query', - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { - id: 'TA0040', - name: 'impact', - reference: 'https://attack.mitre.org/tactics/TA0040/', - }, - technique: [ - { - id: 'T1499', - name: 'endpoint denial of service', - reference: 'https://attack.mitre.org/techniques/T1499/', - subtechnique: [], - }, - ], - }, - ], - references: ['http://www.example.com', 'https://ww.example.com'], - note: '# Investigative notes', - version: 1, - exceptionsList: [ - /** - TODO: fix this mock. Which the typing has revealed is wrong - { - field: 'source.ip', - values_operator: 'included', - values_type: 'exists', - }, - { - field: 'host.name', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'rock01', - }, - ], - and: [ - { - field: 'host.id', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: '123', - }, - { - name: '678', - }, - ], - }, - ], - },*/ - ], - /** - * The fields below were missing as the type was partial and hence not technically correct - */ - author: [], - buildingBlockType: undefined, - eventCategoryOverride: undefined, - license: undefined, - savedId: undefined, - interval: undefined, - riskScoreMapping: undefined, - ruleNameOverride: undefined, - name: undefined, - severityMapping: undefined, - tags: undefined, - threshold: undefined, - threatFilters: undefined, - threatIndex: undefined, - threatIndicatorPath: undefined, - threatQuery: undefined, - threatMapping: undefined, - threatLanguage: undefined, - concurrentSearches: undefined, - itemsPerSearch: undefined, - timestampOverride: undefined, - }, - createdAt: new Date('2019-12-13T16:40:33.400Z'), - updatedAt: new Date('2019-12-13T16:40:33.400Z'), - schedule: { interval: '5m' }, - enabled: true, - actions: [], - throttle: null, - notifyWhen: null, - createdBy: 'elastic', - updatedBy: 'elastic', - apiKeyOwner: 'elastic', - muteAll: false, - mutedInstanceIds: [], - scheduledTaskId: '2dabe330-0702-11ea-8b50-773b89126888', - executionStatus: { - status: 'unknown', - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - }, -}; +import { getAlertMock } from '../routes/__mocks__/request_responses'; +import { getMlRuleParams, getQueryRuleParams } from '../schemas/rule_schemas.mock'; export const getPatchRulesOptionsMock = (): PatchRulesOptions => ({ author: ['Elastic'], @@ -194,7 +59,7 @@ export const getPatchRulesOptionsMock = (): PatchRulesOptions => ({ version: 1, exceptionsList: [], actions: [], - rule, + rule: getAlertMock(getQueryRuleParams()), }); export const getPatchMlRulesOptionsMock = (): PatchRulesOptions => ({ @@ -245,5 +110,5 @@ export const getPatchMlRulesOptionsMock = (): PatchRulesOptions => ({ version: 1, exceptionsList: [], actions: [], - rule, + rule: getAlertMock(getMlRuleParams()), }); 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 755a8cd6f1e58a..bf769e46ab7bd1 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 @@ -13,8 +13,8 @@ import { PatchRulesOptions } from './types'; import { addTags } from './add_tags'; import { calculateVersion, calculateName, calculateInterval, removeUndefined } from './utils'; import { ruleStatusSavedObjectsClientFactory } from '../signals/rule_status_saved_objects_client'; -import { internalRuleUpdate } from '../schemas/rule_schemas'; -import { RuleTypeParams } from '../types'; +import { internalRuleUpdate, RuleParams } from '../schemas/rule_schemas'; +import { normalizeThresholdObject } from '../../../../common/detection_engine/utils'; class PatchError extends Error { public readonly statusCode: number; @@ -73,7 +73,7 @@ export const patchRules = async ({ anomalyThreshold, machineLearningJobId, actions, -}: PatchRulesOptions): Promise | null> => { +}: PatchRulesOptions): Promise | null> => { if (rule == null) { return null; } @@ -151,7 +151,7 @@ export const patchRules = async ({ severity, severityMapping, threat, - threshold, + threshold: threshold ? normalizeThresholdObject(threshold) : undefined, threatFilters, threatIndex, threatQuery, @@ -187,13 +187,10 @@ export const patchRules = async ({ throw new PatchError(`Applying patch would create invalid rule: ${errors}`, 400); } - /** - * TODO: Remove this use of `as` by utilizing the proper type - */ - const update = (await alertsClient.update({ + const update = await alertsClient.update({ id: rule.id, data: validated, - })) as PartialAlert; + }); if (rule.enabled && enabled === false) { await alertsClient.disable({ id: rule.id }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/read_rules.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/read_rules.test.ts index 21e7ba5bc626f3..ce823842913038 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/read_rules.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/read_rules.test.ts @@ -7,7 +7,8 @@ import { readRules } from './read_rules'; import { alertsClientMock } from '../../../../../alerting/server/mocks'; -import { getResult, getFindResultWithSingleHit } from '../routes/__mocks__/request_responses'; +import { getAlertMock, getFindResultWithSingleHit } from '../routes/__mocks__/request_responses'; +import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; export class TestError extends Error { constructor() { @@ -29,18 +30,18 @@ describe('read_rules', () => { describe('readRules', () => { test('should return the output from alertsClient if id is set but ruleId is undefined', async () => { const alertsClient = alertsClientMock.create(); - alertsClient.get.mockResolvedValue(getResult()); + alertsClient.get.mockResolvedValue(getAlertMock(getQueryRuleParams())); const rule = await readRules({ alertsClient, id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', ruleId: undefined, }); - expect(rule).toEqual(getResult()); + expect(rule).toEqual(getAlertMock(getQueryRuleParams())); }); test('should return null if saved object found by alerts client given id is not alert type', async () => { const alertsClient = alertsClientMock.create(); - const result = getResult(); + const result = getAlertMock(getQueryRuleParams()); // @ts-expect-error delete result.alertTypeId; alertsClient.get.mockResolvedValue(result); @@ -85,7 +86,7 @@ describe('read_rules', () => { test('should return the output from alertsClient if id is undefined but ruleId is set', async () => { const alertsClient = alertsClientMock.create(); - alertsClient.get.mockResolvedValue(getResult()); + alertsClient.get.mockResolvedValue(getAlertMock(getQueryRuleParams())); alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); const rule = await readRules({ @@ -93,12 +94,12 @@ describe('read_rules', () => { id: undefined, ruleId: 'rule-1', }); - expect(rule).toEqual(getResult()); + expect(rule).toEqual(getAlertMock(getQueryRuleParams())); }); test('should return null if the output from alertsClient with ruleId set is empty', async () => { const alertsClient = alertsClientMock.create(); - alertsClient.get.mockResolvedValue(getResult()); + alertsClient.get.mockResolvedValue(getAlertMock(getQueryRuleParams())); alertsClient.find.mockResolvedValue({ data: [], page: 0, perPage: 1, total: 0 }); const rule = await readRules({ @@ -111,7 +112,7 @@ describe('read_rules', () => { test('should return the output from alertsClient if id is null but ruleId is set', async () => { const alertsClient = alertsClientMock.create(); - alertsClient.get.mockResolvedValue(getResult()); + alertsClient.get.mockResolvedValue(getAlertMock(getQueryRuleParams())); alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); const rule = await readRules({ @@ -119,12 +120,12 @@ describe('read_rules', () => { id: undefined, ruleId: 'rule-1', }); - expect(rule).toEqual(getResult()); + expect(rule).toEqual(getAlertMock(getQueryRuleParams())); }); test('should return null if id and ruleId are undefined', async () => { const alertsClient = alertsClientMock.create(); - alertsClient.get.mockResolvedValue(getResult()); + alertsClient.get.mockResolvedValue(getAlertMock(getQueryRuleParams())); alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); const rule = await readRules({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/read_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/read_rules.ts index ed84c1a0aba43f..62f8e7642cc640 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/read_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/read_rules.ts @@ -7,7 +7,7 @@ import { SanitizedAlert } from '../../../../../alerting/common'; import { INTERNAL_RULE_ID_KEY } from '../../../../common/constants'; -import { RuleTypeParams } from '../types'; +import { RuleParams } from '../schemas/rule_schemas'; import { findRules } from './find_rules'; import { isAlertType, ReadRuleOptions } from './types'; @@ -23,7 +23,7 @@ export const readRules = async ({ alertsClient, id, ruleId, -}: ReadRuleOptions): Promise | null> => { +}: ReadRuleOptions): Promise | null> => { if (id != null) { try { const rule = await alertsClient.get({ id }); 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 13a255d1b56d48..2a87b008293216 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 @@ -102,10 +102,11 @@ import { import { AlertsClient, PartialAlert } from '../../../../../alerting/server'; import { Alert, SanitizedAlert } from '../../../../../alerting/common'; import { SIGNALS_ID } from '../../../../common/constants'; -import { RuleTypeParams, PartialFilter } from '../types'; +import { PartialFilter } from '../types'; import { ListArrayOrUndefined, ListArray } from '../../../../common/detection_engine/schemas/types'; +import { RuleParams } from '../schemas/rule_schemas'; -export type RuleAlertType = Alert; +export type RuleAlertType = Alert; // eslint-disable-next-line @typescript-eslint/no-explicit-any export interface IRuleStatusSOAttributes extends Record { @@ -174,13 +175,13 @@ export interface Clients { } export const isAlertTypes = ( - partialAlert: Array> + partialAlert: Array> ): partialAlert is RuleAlertType[] => { return partialAlert.every((rule) => isAlertType(rule)); }; export const isAlertType = ( - partialAlert: PartialAlert + partialAlert: PartialAlert ): partialAlert is RuleAlertType => { return partialAlert.alertTypeId === SIGNALS_ID; }; @@ -310,7 +311,7 @@ export interface PatchRulesOptions { version: VersionOrUndefined; exceptionsList: ListArrayOrUndefined; actions: RuleAlertAction[] | undefined; - rule: SanitizedAlert | null; + rule: SanitizedAlert | null; } export interface ReadRuleOptions { 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 44e68587ac5034..f3ee7e251c02da 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 @@ -11,7 +11,8 @@ import { AddPrepackagedRulesSchemaDecoded } from '../../../../common/detection_e import { AlertsClient, PartialAlert } from '../../../../../alerting/server'; import { patchRules } from './patch_rules'; import { readRules } from './read_rules'; -import { PartialFilter, RuleTypeParams } from '../types'; +import { PartialFilter } from '../types'; +import { RuleParams } from '../schemas/rule_schemas'; /** * How many rules to update at a time is set to 50 from errors coming from @@ -73,7 +74,7 @@ export const createPromises = ( savedObjectsClient: SavedObjectsClientContract, rules: AddPrepackagedRulesSchemaDecoded[], outputIndex: string -): Array | null>> => { +): Array | null>> => { return rules.map(async (rule) => { const { author, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.test.ts index 083191329878b8..48b89053845662 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.test.ts @@ -5,17 +5,18 @@ * 2.0. */ -import { getResult, getMlResult } from '../routes/__mocks__/request_responses'; +import { getAlertMock } from '../routes/__mocks__/request_responses'; import { updateRules } from './update_rules'; import { getUpdateRulesOptionsMock, getUpdateMlRulesOptionsMock } from './update_rules.mock'; import { AlertsClientMock } from '../../../../../alerting/server/alerts_client.mock'; +import { getMlRuleParams, getQueryRuleParams } from '../schemas/rule_schemas.mock'; describe('updateRules', () => { it('should call alertsClient.disable if the rule was enabled and enabled is false', async () => { const rulesOptionsMock = getUpdateRulesOptionsMock(); rulesOptionsMock.ruleUpdate.enabled = false; ((rulesOptionsMock.alertsClient as unknown) as AlertsClientMock).get.mockResolvedValue( - getResult() + getAlertMock(getQueryRuleParams()) ); await updateRules(rulesOptionsMock); @@ -32,7 +33,7 @@ describe('updateRules', () => { rulesOptionsMock.ruleUpdate.enabled = true; ((rulesOptionsMock.alertsClient as unknown) as AlertsClientMock).get.mockResolvedValue({ - ...getResult(), + ...getAlertMock(getQueryRuleParams()), enabled: false, }); @@ -50,7 +51,7 @@ describe('updateRules', () => { rulesOptionsMock.ruleUpdate.enabled = true; ((rulesOptionsMock.alertsClient as unknown) as AlertsClientMock).get.mockResolvedValue( - getMlResult() + getAlertMock(getMlRuleParams()) ); await updateRules(rulesOptionsMock); 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 fc9c32bca1c4ca..b0c8cd6c4dd694 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 @@ -15,15 +15,14 @@ import { UpdateRulesOptions } from './types'; import { addTags } from './add_tags'; import { ruleStatusSavedObjectsClientFactory } from '../signals/rule_status_saved_objects_client'; import { typeSpecificSnakeToCamel } from '../schemas/rule_converters'; -import { InternalRuleUpdate } from '../schemas/rule_schemas'; -import { RuleTypeParams } from '../types'; +import { InternalRuleUpdate, RuleParams } from '../schemas/rule_schemas'; export const updateRules = async ({ alertsClient, savedObjectsClient, defaultOutputIndex, ruleUpdate, -}: UpdateRulesOptions): Promise | null> => { +}: UpdateRulesOptions): Promise | null> => { const existingRule = await readRules({ alertsClient, ruleId: ruleUpdate.rule_id, @@ -79,13 +78,10 @@ export const updateRules = async ({ notifyWhen: null, }; - /** - * TODO: Remove this use of `as` by utilizing the proper type - */ - const update = (await alertsClient.update({ + const update = await alertsClient.update({ id: existingRule.id, data: newInternalRule, - })) as PartialAlert; + }); if (existingRule.enabled && enabled === false) { await alertsClient.disable({ id: existingRule.id }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts index 58ce1e7e144602..65cf1d2f723c6e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts @@ -6,8 +6,14 @@ */ import uuid from 'uuid'; -import { InternalRuleCreate, InternalRuleResponse, TypeSpecificRuleParams } from './rule_schemas'; -import { normalizeThresholdField } from '../../../../common/detection_engine/utils'; +import { SavedObject } from 'kibana/server'; +import { normalizeThresholdObject } from '../../../../common/detection_engine/utils'; +import { + InternalRuleCreate, + RuleParams, + TypeSpecificRuleParams, + BaseRuleParams, +} from './rule_schemas'; import { assertUnreachable } from '../../../../common/utility_types'; import { CreateRulesSchema, @@ -20,6 +26,9 @@ import { AppClient } from '../../../types'; import { addTags } from '../rules/add_tags'; import { DEFAULT_MAX_SIGNALS, SERVER_APP_ID, SIGNALS_ID } from '../../../../common/constants'; import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; +import { Alert } from '../../../../../alerting/common'; +import { IRuleSavedAttributesSavedObjectAttributes } from '../rules/types'; +import { transformTags } from '../routes/rules/utils'; // These functions provide conversions from the request API schema to the internal rule schema and from the internal rule schema // to the response API schema. This provides static type-check assurances that the internal schema is in sync with the API schema for @@ -87,7 +96,7 @@ export const typeSpecificSnakeToCamel = (params: CreateTypeSpecific): TypeSpecif query: params.query, filters: params.filters, savedId: params.saved_id, - threshold: params.threshold, + threshold: normalizeThresholdObject(params.threshold), }; } case 'machine_learning': { @@ -176,6 +185,7 @@ export const typeSpecificCamelToSnake = (params: TypeSpecificRuleParams): Respon threat_mapping: params.threatMapping, threat_language: params.threatLanguage, threat_index: params.threatIndex, + threat_indicator_path: params.threatIndicatorPath, concurrent_searches: params.concurrentSearches, items_per_search: params.itemsPerSearch, }; @@ -208,10 +218,7 @@ export const typeSpecificCamelToSnake = (params: TypeSpecificRuleParams): Respon query: params.query, filters: params.filters, saved_id: params.savedId, - threshold: { - ...params.threshold, - field: normalizeThresholdField(params.threshold.field), - }, + threshold: params.threshold, }; } case 'machine_learning': { @@ -227,47 +234,67 @@ export const typeSpecificCamelToSnake = (params: TypeSpecificRuleParams): Respon } }; +// TODO: separate out security solution defined common params from Alerting framework common params +// so we can explicitly specify the return type of this function +export const commonParamsCamelToSnake = (params: BaseRuleParams) => { + return { + description: params.description, + risk_score: params.riskScore, + severity: params.severity, + building_block_type: params.buildingBlockType, + note: params.note, + license: params.license, + output_index: params.outputIndex, + timeline_id: params.timelineId, + timeline_title: params.timelineTitle, + meta: params.meta, + rule_name_override: params.ruleNameOverride, + timestamp_override: params.timestampOverride, + author: params.author, + false_positives: params.falsePositives, + from: params.from, + rule_id: params.ruleId, + max_signals: params.maxSignals, + risk_score_mapping: params.riskScoreMapping, + severity_mapping: params.severityMapping, + threat: params.threat, + to: params.to, + references: params.references, + version: params.version, + exceptions_list: params.exceptionsList, + immutable: params.immutable, + }; +}; + export const internalRuleToAPIResponse = ( - rule: InternalRuleResponse, - ruleActions: RuleActions + rule: Alert, + ruleActions?: RuleActions | null, + ruleStatus?: SavedObject ): FullResponseSchema => { return { + // Alerting framework params id: rule.id, - immutable: rule.params.immutable, - updated_at: rule.updatedAt, - updated_by: rule.updatedBy, - created_at: rule.createdAt, - created_by: rule.createdBy, + updated_at: rule.updatedAt.toISOString(), + updated_by: rule.updatedBy ?? 'elastic', + created_at: rule.createdAt.toISOString(), + created_by: rule.createdBy ?? 'elastic', name: rule.name, - tags: rule.tags, + tags: transformTags(rule.tags), interval: rule.schedule.interval, enabled: rule.enabled, - throttle: ruleActions.ruleThrottle, - actions: ruleActions.actions, - description: rule.params.description, - risk_score: rule.params.riskScore, - severity: rule.params.severity, - building_block_type: rule.params.buildingBlockType, - note: rule.params.note, - license: rule.params.license, - output_index: rule.params.outputIndex, - timeline_id: rule.params.timelineId, - timeline_title: rule.params.timelineTitle, - meta: rule.params.meta, - rule_name_override: rule.params.ruleNameOverride, - timestamp_override: rule.params.timestampOverride, - author: rule.params.author ?? [], - false_positives: rule.params.falsePositives, - from: rule.params.from, - rule_id: rule.params.ruleId, - max_signals: rule.params.maxSignals, - risk_score_mapping: rule.params.riskScoreMapping ?? [], - severity_mapping: rule.params.severityMapping ?? [], - threat: rule.params.threat, - to: rule.params.to, - references: rule.params.references, - version: rule.params.version, - exceptions_list: rule.params.exceptionsList ?? [], + // Security solution shared rule params + ...commonParamsCamelToSnake(rule.params), + // Type specific security solution rule params ...typeSpecificCamelToSnake(rule.params), + // Actions + throttle: ruleActions?.ruleThrottle || 'no_actions', + actions: ruleActions?.actions ?? [], + // Rule status + status: ruleStatus?.attributes.status ?? undefined, + status_date: ruleStatus?.attributes.statusDate ?? undefined, + last_failure_at: ruleStatus?.attributes.lastFailureAt ?? undefined, + last_success_at: ruleStatus?.attributes.lastSuccessAt ?? undefined, + last_failure_message: ruleStatus?.attributes.lastFailureMessage ?? undefined, + last_success_message: ruleStatus?.attributes.lastSuccessMessage ?? undefined, }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.mock.ts index a855bcb7cb6d06..8c5825325bd2ee 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.mock.ts @@ -5,11 +5,15 @@ * 2.0. */ +import { getThreatMock } from '../../../../common/detection_engine/schemas/types/threat.mock'; import { getListArrayMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; +import { getThreatMappingMock } from '../signals/threat_mapping/build_threat_mapping_filter.mock'; import { BaseRuleParams, EqlRuleParams, MachineLearningRuleParams, + QueryRuleParams, + ThreatRuleParams, ThresholdRuleParams, } from './rule_schemas'; @@ -27,17 +31,19 @@ const getBaseRuleParams = (): BaseRuleParams => { severityMapping: [], license: 'Elastic License', outputIndex: '.siem-signals', - references: ['http://google.com'], + references: ['http://example.com', 'https://example.com'], riskScore: 50, riskScoreMapping: [], ruleNameOverride: undefined, maxSignals: 10000, - note: '', - timelineId: undefined, - timelineTitle: undefined, + note: '# Investigative notes', + timelineId: 'some-timeline-id', + timelineTitle: 'some-timeline-title', timestampOverride: undefined, - meta: undefined, - threat: [], + meta: { + someMeta: 'someField', + }, + threat: getThreatMock(), version: 1, exceptionsList: getListArrayMock(), }; @@ -48,13 +54,19 @@ export const getThresholdRuleParams = (): ThresholdRuleParams => { ...getBaseRuleParams(), type: 'threshold', language: 'kuery', - index: ['some-index'], - query: 'host.name: *', + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + query: 'user.name: root or user.name: admin', filters: undefined, savedId: undefined, threshold: { - field: 'host.id', + field: ['host.id'], value: 5, + cardinality: [ + { + field: 'source.ip', + value: 11, + }, + ], }, }; }; @@ -79,3 +91,43 @@ export const getMlRuleParams = (): MachineLearningRuleParams => { machineLearningJobId: 'my-job', }; }; + +export const getQueryRuleParams = (): QueryRuleParams => { + return { + ...getBaseRuleParams(), + type: 'query', + language: 'kuery', + query: 'user.name: root or user.name: admin', + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + filters: [ + { + query: { + match_phrase: { + 'host.name': 'some-host', + }, + }, + }, + ], + savedId: undefined, + }; +}; + +export const getThreatRuleParams = (): ThreatRuleParams => { + return { + ...getBaseRuleParams(), + type: 'threat_match', + language: 'kuery', + query: '*:*', + index: ['some-index'], + filters: undefined, + savedId: undefined, + threatQuery: 'threat-query', + threatFilters: undefined, + threatIndex: ['some-threat-index'], + threatLanguage: 'kuery', + threatMapping: getThreatMappingMock(), + threatIndicatorPath: '', + concurrentSearches: undefined, + itemsPerSearch: undefined, + }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts index 144b751491b2c7..cd2b5d0b9eda7e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts @@ -7,7 +7,7 @@ import * as t from 'io-ts'; -import { listArrayOrUndefined } from '../../../../common/detection_engine/schemas/types/lists'; +import { listArray } from '../../../../common/detection_engine/schemas/types/lists'; import { threat_mapping, threat_index, @@ -17,7 +17,7 @@ import { threatIndicatorPathOrUndefined, } from '../../../../common/detection_engine/schemas/types/threat_mapping'; import { - authorOrUndefined, + author, buildingBlockTypeOrUndefined, description, enabled, @@ -39,10 +39,10 @@ import { machine_learning_job_id, max_signals, risk_score, - riskScoreMappingOrUndefined, + risk_score_mapping, ruleNameOverrideOrUndefined, severity, - severityMappingOrUndefined, + severity_mapping, tags, timestampOverrideOrUndefined, threats, @@ -52,7 +52,7 @@ import { eventCategoryOverrideOrUndefined, savedIdOrUndefined, saved_id, - threshold, + thresholdNormalized, anomaly_threshold, actionsCamel, throttleOrNull, @@ -66,7 +66,7 @@ import { SIGNALS_ID, SERVER_APP_ID } from '../../../../common/constants'; const nonEqlLanguages = t.keyof({ kuery: null, lucene: null }); export const baseRuleParams = t.exact( t.type({ - author: authorOrUndefined, + author, buildingBlockType: buildingBlockTypeOrUndefined, description, note: noteOrUndefined, @@ -82,16 +82,16 @@ export const baseRuleParams = t.exact( // maxSignals not used in ML rules but probably should be used maxSignals: max_signals, riskScore: risk_score, - riskScoreMapping: riskScoreMappingOrUndefined, + riskScoreMapping: risk_score_mapping, ruleNameOverride: ruleNameOverrideOrUndefined, severity, - severityMapping: severityMappingOrUndefined, + severityMapping: severity_mapping, timestampOverride: timestampOverrideOrUndefined, threat: threats, to, references, version, - exceptionsList: listArrayOrUndefined, + exceptionsList: listArray, }) ); export type BaseRuleParams = t.TypeOf; @@ -159,7 +159,7 @@ const thresholdSpecificRuleParams = t.type({ query, filters: filtersOrUndefined, savedId: savedIdOrUndefined, - threshold, + threshold: thresholdNormalized, }); export const thresholdRuleParams = t.intersection([baseRuleParams, thresholdSpecificRuleParams]); export type ThresholdRuleParams = t.TypeOf; 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 8c9b19a0929d2e..2ef72c22bbecf0 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 @@ -11,68 +11,20 @@ import type { SignalSearchResponse, BulkResponse, BulkItem, - RuleAlertAttributes, SignalHit, WrappedSignalHit, + AlertAttributes, } from '../types'; import { SavedObject, SavedObjectsFindResponse } from '../../../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../../../src/core/server/mocks'; -import { RuleTypeParams } from '../../types'; import { IRuleStatusSOAttributes } from '../../rules/types'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; import { getListArrayMock } from '../../../../../common/detection_engine/schemas/types/lists.mock'; import { RulesSchema } from '../../../../../common/detection_engine/schemas/response'; +import { RuleParams } from '../../schemas/rule_schemas'; +import { getThreatMock } from '../../../../../common/detection_engine/schemas/types/threat.mock'; -export const sampleRuleAlertParams = ( - maxSignals?: number | undefined, - riskScore?: number | undefined -): RuleTypeParams => ({ - author: ['Elastic'], - buildingBlockType: 'default', - ruleId: 'rule-1', - description: 'Detecting root and admin users', - eventCategoryOverride: undefined, - falsePositives: [], - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - type: 'query', - from: 'now-6m', - to: 'now', - severity: 'high', - severityMapping: [], - query: 'user.name: root or user.name: admin', - language: 'kuery', - license: 'Elastic License', - outputIndex: '.siem-signals', - references: ['http://google.com'], - riskScore: riskScore ? riskScore : 50, - riskScoreMapping: [], - ruleNameOverride: undefined, - maxSignals: maxSignals ? maxSignals : 10000, - note: '', - anomalyThreshold: undefined, - machineLearningJobId: undefined, - filters: undefined, - savedId: undefined, - threshold: undefined, - threatFilters: undefined, - threatQuery: undefined, - threatMapping: undefined, - threatIndex: undefined, - threatIndicatorPath: undefined, - threatLanguage: undefined, - timelineId: undefined, - timelineTitle: undefined, - timestampOverride: undefined, - meta: undefined, - threat: undefined, - version: 1, - exceptionsList: getListArrayMock(), - concurrentSearches: undefined, - itemsPerSearch: undefined, -}); - -export const sampleRuleSO = (): SavedObject => { +export const sampleRuleSO = (params: T): SavedObject> => { return { id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', type: 'alert', @@ -90,7 +42,7 @@ export const sampleRuleSO = (): SavedObject => { interval: '5m', }, throttle: 'no_actions', - params: sampleRuleAlertParams(), + params, }, references: [], }; @@ -110,21 +62,33 @@ export const expectedRule = (): RulesSchema => { output_index: '.siem-signals', description: 'Detecting root and admin users', from: 'now-6m', + filters: [ + { + query: { + match_phrase: { + 'host.name': 'some-host', + }, + }, + }, + ], immutable: false, index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], interval: '5m', language: 'kuery', license: 'Elastic License', + meta: { + someMeta: 'someField', + }, name: 'rule-name', query: 'user.name: root or user.name: admin', - references: ['http://google.com'], + references: ['http://example.com', 'https://example.com'], severity: 'high', severity_mapping: [], tags: ['some fake tag 1', 'some fake tag 2'], - threat: [], + threat: getThreatMock(), type: 'query', to: 'now', - note: '', + note: '# Investigative notes', enabled: true, created_by: 'sample user', updated_by: 'sample user', @@ -132,6 +96,8 @@ export const expectedRule = (): RulesSchema => { updated_at: '2020-03-27T22:55:59.577Z', created_at: '2020-03-27T22:55:59.577Z', throttle: 'no_actions', + timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', exceptions_list: getListArrayMock(), }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts index 708aefc4d86147..743d9580218a3a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts @@ -6,13 +6,12 @@ */ import { - sampleRuleAlertParams, sampleDocNoSortId, - sampleRuleGuid, sampleIdGuid, sampleDocWithAncestors, sampleRuleSO, sampleWrappedSignalHit, + expectedRule, } from './__mocks__/es_results'; import { buildBulkBody, @@ -22,8 +21,8 @@ import { objectArrayIntersection, } from './build_bulk_body'; import { SignalHit, SignalSourceHit } from './types'; -import { getListArrayMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; import { SIGNALS_TEMPLATE_VERSION } from '../routes/index/get_signals_template'; +import { getQueryRuleParams, getThresholdRuleParams } from '../schemas/rule_schemas.mock'; describe('buildBulkBody', () => { beforeEach(() => { @@ -31,31 +30,11 @@ describe('buildBulkBody', () => { }); test('bulk body builds well-defined body', () => { - const sampleParams = sampleRuleAlertParams(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); const doc = sampleDocNoSortId(); // @ts-expect-error @elastic/elasticsearch _source is optional delete doc._source.source; - const fakeSignalSourceHit = buildBulkBody({ - doc, - ruleParams: { - ...sampleParams, - threshold: { - field: ['host.name'], - value: 100, - }, - }, - id: sampleRuleGuid, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', - }); + const fakeSignalSourceHit = buildBulkBody(ruleSO, doc); // Timestamp will potentially always be different so remove it for the test // @ts-expect-error delete fakeSignalSourceHit['@timestamp']; @@ -92,47 +71,7 @@ describe('buildBulkBody', () => { ], original_time: '2020-04-20T21:27:45+0000', status: 'open', - rule: { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - rule_id: 'rule-1', - false_positives: [], - max_signals: 10000, - risk_score: 50, - risk_score_mapping: [], - output_index: '.siem-signals', - description: 'Detecting root and admin users', - from: 'now-6m', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - language: 'kuery', - license: 'Elastic License', - name: 'rule-name', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - severity: 'high', - severity_mapping: [], - tags: ['some fake tag 1', 'some fake tag 2'], - threat: [], - threshold: { - field: ['host.name'], - value: 100, - }, - throttle: 'no_actions', - type: 'query', - to: 'now', - note: '', - enabled: true, - created_by: 'elastic', - updated_by: 'elastic', - version: 1, - created_at: fakeSignalSourceHit.signal.rule?.created_at, - updated_at: fakeSignalSourceHit.signal.rule?.updated_at, - exceptions_list: getListArrayMock(), - }, + rule: expectedRule(), depth: 1, }, }; @@ -140,7 +79,7 @@ describe('buildBulkBody', () => { }); test('bulk body builds well-defined body with threshold results', () => { - const sampleParams = sampleRuleAlertParams(); + const ruleSO = sampleRuleSO(getThresholdRuleParams()); const baseDoc = sampleDocNoSortId(); const doc: SignalSourceHit = { ...baseDoc, @@ -159,27 +98,7 @@ describe('buildBulkBody', () => { }; // @ts-expect-error @elastic/elasticsearch _source is optional delete doc._source.source; - const fakeSignalSourceHit = buildBulkBody({ - doc, - ruleParams: { - ...sampleParams, - threshold: { - field: [], - value: 4, - }, - }, - id: sampleRuleGuid, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', - }); + const fakeSignalSourceHit = buildBulkBody(ruleSO, doc); // Timestamp will potentially always be different so remove it for the test // @ts-expect-error delete fakeSignalSourceHit['@timestamp']; @@ -217,45 +136,19 @@ describe('buildBulkBody', () => { original_time: '2020-04-20T21:27:45+0000', status: 'open', rule: { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - rule_id: 'rule-1', - false_positives: [], - max_signals: 10000, - risk_score: 50, - risk_score_mapping: [], - output_index: '.siem-signals', - description: 'Detecting root and admin users', - from: 'now-6m', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - language: 'kuery', - license: 'Elastic License', - name: 'rule-name', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - severity: 'high', - severity_mapping: [], - tags: ['some fake tag 1', 'some fake tag 2'], - threat: [], + ...expectedRule(), + filters: undefined, + type: 'threshold', threshold: { - field: [], - value: 4, + field: ['host.id'], + value: 5, + cardinality: [ + { + field: 'source.ip', + value: 11, + }, + ], }, - throttle: 'no_actions', - type: 'query', - to: 'now', - note: '', - enabled: true, - created_by: 'elastic', - updated_by: 'elastic', - version: 1, - created_at: fakeSignalSourceHit.signal.rule?.created_at, - updated_at: fakeSignalSourceHit.signal.rule?.updated_at, - exceptions_list: getListArrayMock(), }, threshold_result: { terms: [ @@ -272,7 +165,7 @@ describe('buildBulkBody', () => { }); test('bulk body builds original_event if it exists on the event to begin with', () => { - const sampleParams = sampleRuleAlertParams(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); const doc = sampleDocNoSortId(); // @ts-expect-error @elastic/elasticsearch _source is optional delete doc._source.source; @@ -283,21 +176,7 @@ describe('buildBulkBody', () => { dataset: 'socket', kind: 'event', }; - const fakeSignalSourceHit = buildBulkBody({ - doc, - ruleParams: sampleParams, - id: sampleRuleGuid, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', - }); + const fakeSignalSourceHit = buildBulkBody(ruleSO, doc); // Timestamp will potentially always be different so remove it for the test // @ts-expect-error delete fakeSignalSourceHit['@timestamp']; @@ -343,43 +222,7 @@ describe('buildBulkBody', () => { ], original_time: '2020-04-20T21:27:45+0000', status: 'open', - rule: { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - rule_id: 'rule-1', - false_positives: [], - max_signals: 10000, - risk_score: 50, - risk_score_mapping: [], - output_index: '.siem-signals', - description: 'Detecting root and admin users', - from: 'now-6m', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - language: 'kuery', - license: 'Elastic License', - name: 'rule-name', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - severity: 'high', - severity_mapping: [], - tags: ['some fake tag 1', 'some fake tag 2'], - type: 'query', - to: 'now', - note: '', - enabled: true, - created_by: 'elastic', - updated_by: 'elastic', - version: 1, - created_at: fakeSignalSourceHit.signal.rule?.created_at, - updated_at: fakeSignalSourceHit.signal.rule?.updated_at, - throttle: 'no_actions', - threat: [], - exceptions_list: getListArrayMock(), - }, + rule: expectedRule(), depth: 1, }, }; @@ -387,7 +230,7 @@ describe('buildBulkBody', () => { }); test('bulk body builds original_event if it exists on the event to begin with but no kind information', () => { - const sampleParams = sampleRuleAlertParams(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); const doc = sampleDocNoSortId(); // @ts-expect-error @elastic/elasticsearch _source is optional delete doc._source.source; @@ -397,21 +240,7 @@ describe('buildBulkBody', () => { module: 'system', dataset: 'socket', }; - const fakeSignalSourceHit = buildBulkBody({ - doc, - ruleParams: sampleParams, - id: sampleRuleGuid, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', - }); + const fakeSignalSourceHit = buildBulkBody(ruleSO, doc); // Timestamp will potentially always be different so remove it for the test // @ts-expect-error delete fakeSignalSourceHit['@timestamp']; @@ -456,43 +285,7 @@ describe('buildBulkBody', () => { ], original_time: '2020-04-20T21:27:45+0000', status: 'open', - rule: { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - rule_id: 'rule-1', - false_positives: [], - max_signals: 10000, - risk_score: 50, - risk_score_mapping: [], - output_index: '.siem-signals', - description: 'Detecting root and admin users', - from: 'now-6m', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - language: 'kuery', - license: 'Elastic License', - name: 'rule-name', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - severity: 'high', - severity_mapping: [], - threat: [], - tags: ['some fake tag 1', 'some fake tag 2'], - type: 'query', - to: 'now', - note: '', - enabled: true, - created_by: 'elastic', - updated_by: 'elastic', - version: 1, - created_at: fakeSignalSourceHit.signal.rule?.created_at, - updated_at: fakeSignalSourceHit.signal.rule?.updated_at, - throttle: 'no_actions', - exceptions_list: getListArrayMock(), - }, + rule: expectedRule(), depth: 1, }, }; @@ -500,7 +293,7 @@ describe('buildBulkBody', () => { }); test('bulk body builds original_event if it exists on the event to begin with with only kind information', () => { - const sampleParams = sampleRuleAlertParams(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); const doc = sampleDocNoSortId(); // @ts-expect-error @elastic/elasticsearch _source is optional delete doc._source.source; @@ -508,21 +301,7 @@ describe('buildBulkBody', () => { doc._source.event = { kind: 'event', }; - const fakeSignalSourceHit = buildBulkBody({ - doc, - ruleParams: sampleParams, - id: sampleRuleGuid, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', - }); + const fakeSignalSourceHit = buildBulkBody(ruleSO, doc); // Timestamp will potentially always be different so remove it for the test // @ts-expect-error delete fakeSignalSourceHit['@timestamp']; @@ -562,43 +341,7 @@ describe('buildBulkBody', () => { ], original_time: '2020-04-20T21:27:45+0000', status: 'open', - rule: { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - rule_id: 'rule-1', - false_positives: [], - max_signals: 10000, - risk_score: 50, - risk_score_mapping: [], - output_index: '.siem-signals', - description: 'Detecting root and admin users', - from: 'now-6m', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - language: 'kuery', - license: 'Elastic License', - name: 'rule-name', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - severity: 'high', - severity_mapping: [], - tags: ['some fake tag 1', 'some fake tag 2'], - threat: [], - type: 'query', - to: 'now', - note: '', - enabled: true, - created_by: 'elastic', - updated_by: 'elastic', - version: 1, - updated_at: fakeSignalSourceHit.signal.rule?.updated_at, - created_at: fakeSignalSourceHit.signal.rule?.created_at, - throttle: 'no_actions', - exceptions_list: getListArrayMock(), - }, + rule: expectedRule(), depth: 1, }, }; @@ -606,7 +349,7 @@ describe('buildBulkBody', () => { }); test('bulk body builds "original_signal" if it exists already as a numeric', () => { - const sampleParams = sampleRuleAlertParams(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); const sampleDoc = sampleDocNoSortId(); // @ts-expect-error @elastic/elasticsearch _source is optional delete sampleDoc._source.source; @@ -617,21 +360,7 @@ describe('buildBulkBody', () => { signal: 123, }, } as unknown) as SignalSourceHit; - const { '@timestamp': timestamp, ...fakeSignalSourceHit } = buildBulkBody({ - doc, - ruleParams: sampleParams, - id: sampleRuleGuid, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', - }); + const { '@timestamp': timestamp, ...fakeSignalSourceHit } = buildBulkBody(ruleSO, doc); const expected: Omit & { someKey: string } = { someKey: 'someValue', event: { @@ -666,43 +395,7 @@ describe('buildBulkBody', () => { ], original_time: '2020-04-20T21:27:45+0000', status: 'open', - rule: { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - rule_id: 'rule-1', - false_positives: [], - max_signals: 10000, - risk_score: 50, - risk_score_mapping: [], - output_index: '.siem-signals', - description: 'Detecting root and admin users', - from: 'now-6m', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - language: 'kuery', - license: 'Elastic License', - name: 'rule-name', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - severity: 'high', - severity_mapping: [], - tags: ['some fake tag 1', 'some fake tag 2'], - threat: [], - type: 'query', - to: 'now', - note: '', - enabled: true, - created_by: 'elastic', - updated_by: 'elastic', - version: 1, - updated_at: fakeSignalSourceHit.signal.rule?.updated_at, - created_at: fakeSignalSourceHit.signal.rule?.created_at, - throttle: 'no_actions', - exceptions_list: getListArrayMock(), - }, + rule: expectedRule(), depth: 1, }, }; @@ -710,7 +403,7 @@ describe('buildBulkBody', () => { }); test('bulk body builds "original_signal" if it exists already as an object', () => { - const sampleParams = sampleRuleAlertParams(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); const sampleDoc = sampleDocNoSortId(); // @ts-expect-error @elastic/elasticsearch _source is optional delete sampleDoc._source.source; @@ -721,21 +414,7 @@ describe('buildBulkBody', () => { signal: { child_1: { child_2: 'nested data' } }, }, } as unknown) as SignalSourceHit; - const { '@timestamp': timestamp, ...fakeSignalSourceHit } = buildBulkBody({ - doc, - ruleParams: sampleParams, - id: sampleRuleGuid, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', - }); + const { '@timestamp': timestamp, ...fakeSignalSourceHit } = buildBulkBody(ruleSO, doc); const expected: Omit & { someKey: string } = { someKey: 'someValue', event: { @@ -770,43 +449,7 @@ describe('buildBulkBody', () => { ], original_time: '2020-04-20T21:27:45+0000', status: 'open', - rule: { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - rule_id: 'rule-1', - false_positives: [], - max_signals: 10000, - risk_score: 50, - risk_score_mapping: [], - output_index: '.siem-signals', - description: 'Detecting root and admin users', - from: 'now-6m', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - language: 'kuery', - license: 'Elastic License', - name: 'rule-name', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - severity: 'high', - severity_mapping: [], - tags: ['some fake tag 1', 'some fake tag 2'], - threat: [], - type: 'query', - to: 'now', - note: '', - enabled: true, - created_by: 'elastic', - updated_by: 'elastic', - version: 1, - updated_at: fakeSignalSourceHit.signal.rule?.updated_at, - created_at: fakeSignalSourceHit.signal.rule?.created_at, - throttle: 'no_actions', - exceptions_list: getListArrayMock(), - }, + rule: expectedRule(), depth: 1, }, }; @@ -822,7 +465,7 @@ describe('buildSignalFromSequence', () => { const block2 = sampleWrappedSignalHit(); block2._source.new_key = 'new_key_value'; const blocks = [block1, block2]; - const ruleSO = sampleRuleSO(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); const signal = buildSignalFromSequence(blocks, ruleSO); // Timestamp will potentially always be different so remove it for the test // @ts-expect-error @@ -893,43 +536,7 @@ describe('buildSignalFromSequence', () => { }, ], status: 'open', - rule: { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - rule_id: 'rule-1', - false_positives: [], - max_signals: 10000, - risk_score: 50, - risk_score_mapping: [], - output_index: '.siem-signals', - description: 'Detecting root and admin users', - from: 'now-6m', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - language: 'kuery', - license: 'Elastic License', - name: 'rule-name', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - severity: 'high', - severity_mapping: [], - tags: ['some fake tag 1', 'some fake tag 2'], - threat: [], - type: 'query', - to: 'now', - note: '', - enabled: true, - created_by: 'sample user', - updated_by: 'sample user', - version: 1, - updated_at: ruleSO.updated_at ?? '', - created_at: ruleSO.attributes.createdAt, - throttle: 'no_actions', - exceptions_list: getListArrayMock(), - }, + rule: expectedRule(), depth: 2, group: { id: '269c1f5754bff92fb8040283b687258e99b03e8b2ab1262cc20c82442e5de5ea', @@ -944,7 +551,7 @@ describe('buildSignalFromSequence', () => { const block2 = sampleWrappedSignalHit(); block2._source['@timestamp'] = '2021-05-20T22:28:46+0000'; block2._source.someKey = 'someOtherValue'; - const ruleSO = sampleRuleSO(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); const signal = buildSignalFromSequence([block1, block2], ruleSO); // Timestamp will potentially always be different so remove it for the test // @ts-expect-error @@ -1014,43 +621,7 @@ describe('buildSignalFromSequence', () => { }, ], status: 'open', - rule: { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - rule_id: 'rule-1', - false_positives: [], - max_signals: 10000, - risk_score: 50, - risk_score_mapping: [], - output_index: '.siem-signals', - description: 'Detecting root and admin users', - from: 'now-6m', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - language: 'kuery', - license: 'Elastic License', - name: 'rule-name', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - severity: 'high', - severity_mapping: [], - tags: ['some fake tag 1', 'some fake tag 2'], - threat: [], - type: 'query', - to: 'now', - note: '', - enabled: true, - created_by: 'sample user', - updated_by: 'sample user', - version: 1, - updated_at: ruleSO.updated_at ?? '', - created_at: ruleSO.attributes.createdAt, - throttle: 'no_actions', - exceptions_list: getListArrayMock(), - }, + rule: expectedRule(), depth: 2, group: { id: '269c1f5754bff92fb8040283b687258e99b03e8b2ab1262cc20c82442e5de5ea', @@ -1066,7 +637,7 @@ describe('buildSignalFromEvent', () => { const ancestor = sampleDocWithAncestors().hits.hits[0]; // @ts-expect-error @elastic/elasticsearch _source is optional delete ancestor._source.source; - const ruleSO = sampleRuleSO(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); const signal = buildSignalFromEvent(ancestor, ruleSO, true); // Timestamp will potentially always be different so remove it for the test // @ts-expect-error @@ -1113,43 +684,7 @@ describe('buildSignalFromEvent', () => { }, ], status: 'open', - rule: { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - rule_id: 'rule-1', - false_positives: [], - max_signals: 10000, - risk_score: 50, - risk_score_mapping: [], - output_index: '.siem-signals', - description: 'Detecting root and admin users', - from: 'now-6m', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - language: 'kuery', - license: 'Elastic License', - name: 'rule-name', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - severity: 'high', - severity_mapping: [], - tags: ['some fake tag 1', 'some fake tag 2'], - threat: [], - type: 'query', - to: 'now', - note: '', - enabled: true, - created_by: 'sample user', - updated_by: 'sample user', - version: 1, - updated_at: ruleSO.updated_at ?? '', - created_at: ruleSO.attributes.createdAt, - throttle: 'no_actions', - exceptions_list: getListArrayMock(), - }, + rule: expectedRule(), depth: 2, }, }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts index 0c03c0837e8e1f..10cc1687004470 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts @@ -7,68 +7,26 @@ import { SavedObject } from 'src/core/types'; import { + AlertAttributes, SignalSourceHit, SignalHit, Signal, - RuleAlertAttributes, BaseSignalHit, SignalSource, WrappedSignalHit, } from './types'; -import { buildRule, buildRuleWithoutOverrides, buildRuleWithOverrides } from './build_rule'; +import { buildRuleWithoutOverrides, buildRuleWithOverrides } from './build_rule'; import { additionalSignalFields, buildSignal } from './build_signal'; import { buildEventTypeSignal } from './build_event_type_signal'; -import { EqlSequence, RuleAlertAction } from '../../../../common/detection_engine/types'; -import { RuleTypeParams } from '../types'; +import { EqlSequence } from '../../../../common/detection_engine/types'; import { generateSignalId, wrapBuildingBlocks, wrapSignal } from './utils'; -interface BuildBulkBodyParams { - doc: SignalSourceHit; - ruleParams: RuleTypeParams; - id: string; - actions: RuleAlertAction[]; - name: string; - createdAt: string; - createdBy: string; - updatedAt: string; - updatedBy: string; - interval: string; - enabled: boolean; - tags: string[]; - throttle: string; -} - // format search_after result for signals index. -export const buildBulkBody = ({ - doc, - ruleParams, - id, - name, - actions, - createdAt, - createdBy, - updatedAt, - updatedBy, - interval, - enabled, - tags, - throttle, -}: BuildBulkBodyParams): SignalHit => { - const rule = buildRule({ - actions, - ruleParams, - id, - name, - enabled, - createdAt, - createdBy, - doc, - updatedAt, - updatedBy, - interval, - tags, - throttle, - }); +export const buildBulkBody = ( + ruleSO: SavedObject, + doc: SignalSourceHit +): SignalHit => { + const rule = buildRuleWithOverrides(ruleSO, doc._source!); const signal: Signal = { ...buildSignal([doc], rule), ...additionalSignalFields(doc), @@ -96,7 +54,7 @@ export const buildBulkBody = ({ */ export const buildSignalGroupFromSequence = ( sequence: EqlSequence, - ruleSO: SavedObject, + ruleSO: SavedObject, outputIndex: string ): WrappedSignalHit[] => { const wrappedBuildingBlocks = wrapBuildingBlocks( @@ -137,7 +95,7 @@ export const buildSignalGroupFromSequence = ( export const buildSignalFromSequence = ( events: WrappedSignalHit[], - ruleSO: SavedObject + ruleSO: SavedObject ): SignalHit => { const rule = buildRuleWithoutOverrides(ruleSO); const signal: Signal = buildSignal(events, rule); @@ -161,7 +119,7 @@ export const buildSignalFromSequence = ( export const buildSignalFromEvent = ( event: BaseSignalHit, - ruleSO: SavedObject, + ruleSO: SavedObject, applyOverrides: boolean ): SignalHit => { const rule = applyOverrides diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.test.ts index 757e6728f244ed..412ccf7a40e334 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.test.ts @@ -5,34 +5,40 @@ * 2.0. */ -import { - buildRule, - removeInternalTagsFromRule, - buildRuleWithOverrides, - buildRuleWithoutOverrides, -} from './build_rule'; +import { buildRuleWithOverrides, buildRuleWithoutOverrides } from './build_rule'; import { sampleDocNoSortId, - sampleRuleAlertParams, - sampleRuleGuid, - sampleRuleSO, expectedRule, sampleDocSeverity, + sampleRuleSO, } from './__mocks__/es_results'; import { RulesSchema } from '../../../../common/detection_engine/schemas/response/rules_schema'; -import { getListArrayMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; import { INTERNAL_RULE_ID_KEY, INTERNAL_IMMUTABLE_KEY } from '../../../../common/constants'; -import { getRulesSchemaMock } from '../../../../common/detection_engine/schemas/response/rules_schema.mocks'; -import { RuleTypeParams } from '../types'; +import { getQueryRuleParams, getThreatRuleParams } from '../schemas/rule_schemas.mock'; +import { ThreatRuleParams } from '../schemas/rule_schemas'; -describe('buildRule', () => { - beforeEach(() => { - jest.clearAllMocks(); +describe('buildRuleWithoutOverrides', () => { + test('builds a rule using rule alert', () => { + const ruleSO = sampleRuleSO(getQueryRuleParams()); + const rule = buildRuleWithoutOverrides(ruleSO); + expect(rule).toEqual(expectedRule()); + }); + + test('builds a rule and removes internal tags', () => { + const ruleSO = sampleRuleSO(getQueryRuleParams()); + ruleSO.attributes.tags = [ + 'some fake tag 1', + 'some fake tag 2', + `${INTERNAL_RULE_ID_KEY}:rule-1`, + `${INTERNAL_IMMUTABLE_KEY}:true`, + ]; + const rule = buildRuleWithoutOverrides(ruleSO); + expect(rule.tags).toEqual(['some fake tag 1', 'some fake tag 2']); }); test('it builds a rule as expected with filters present', () => { - const ruleParams = sampleRuleAlertParams(); - ruleParams.filters = [ + const ruleSO = sampleRuleSO(getQueryRuleParams()); + const ruleFilters = [ { query: 'host.name: Rebecca', }, @@ -43,253 +49,14 @@ describe('buildRule', () => { query: 'host.name: Braden', }, ]; - const rule = buildRule({ - actions: [], - doc: sampleDocNoSortId(), - ruleParams, - name: 'some-name', - id: sampleRuleGuid, - enabled: false, - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: 'some interval', - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', - }); - const expected: Partial = { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - created_by: 'elastic', - description: 'Detecting root and admin users', - enabled: false, - false_positives: [], - from: 'now-6m', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: 'some interval', - language: 'kuery', - license: 'Elastic License', - max_signals: 10000, - name: 'some-name', - output_index: '.siem-signals', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - risk_score: 50, - risk_score_mapping: [], - rule_id: 'rule-1', - severity: 'high', - severity_mapping: [], - tags: ['some fake tag 1', 'some fake tag 2'], - threat: [], - to: 'now', - type: 'query', - note: '', - updated_by: 'elastic', - updated_at: rule.updated_at, - created_at: rule.created_at, - throttle: 'no_actions', - filters: [ - { - query: 'host.name: Rebecca', - }, - { - query: 'host.name: Evan', - }, - { - query: 'host.name: Braden', - }, - ], - exceptions_list: getListArrayMock(), - version: 1, - }; - expect(rule).toEqual(expected); - }); - - test('it omits a null value such as if "enabled" is null if is present', () => { - const ruleParams = sampleRuleAlertParams(); - ruleParams.filters = undefined; - const rule = buildRule({ - actions: [], - doc: sampleDocNoSortId(), - ruleParams, - name: 'some-name', - id: sampleRuleGuid, - enabled: true, - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: 'some interval', - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', - }); - const expected: Partial = { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - created_by: 'elastic', - description: 'Detecting root and admin users', - enabled: true, - false_positives: [], - from: 'now-6m', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: 'some interval', - language: 'kuery', - license: 'Elastic License', - max_signals: 10000, - name: 'some-name', - output_index: '.siem-signals', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - risk_score: 50, - risk_score_mapping: [], - rule_id: 'rule-1', - severity: 'high', - severity_mapping: [], - tags: ['some fake tag 1', 'some fake tag 2'], - threat: [], - to: 'now', - type: 'query', - note: '', - updated_by: 'elastic', - version: 1, - updated_at: rule.updated_at, - created_at: rule.created_at, - throttle: 'no_actions', - exceptions_list: getListArrayMock(), - }; - expect(rule).toEqual(expected); - }); - - test('it omits a null value such as if "filters" is undefined if is present', () => { - const ruleParams = sampleRuleAlertParams(); - ruleParams.filters = undefined; - const rule = buildRule({ - actions: [], - doc: sampleDocNoSortId(), - ruleParams, - name: 'some-name', - id: sampleRuleGuid, - enabled: true, - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: 'some interval', - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', - }); - const expected: Partial = { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - created_by: 'elastic', - description: 'Detecting root and admin users', - enabled: true, - false_positives: [], - from: 'now-6m', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: 'some interval', - language: 'kuery', - license: 'Elastic License', - max_signals: 10000, - name: 'some-name', - note: '', - output_index: '.siem-signals', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - risk_score: 50, - risk_score_mapping: [], - rule_id: 'rule-1', - severity: 'high', - severity_mapping: [], - tags: ['some fake tag 1', 'some fake tag 2'], - threat: [], - to: 'now', - type: 'query', - updated_by: 'elastic', - version: 1, - updated_at: rule.updated_at, - created_at: rule.created_at, - throttle: 'no_actions', - exceptions_list: getListArrayMock(), - }; - expect(rule).toEqual(expected); - }); - - test('it builds a rule and removes internal tags', () => { - const ruleParams = sampleRuleAlertParams(); - const rule = buildRule({ - actions: [], - doc: sampleDocNoSortId(), - ruleParams, - name: 'some-name', - id: sampleRuleGuid, - enabled: false, - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: 'some interval', - tags: [ - 'some fake tag 1', - 'some fake tag 2', - `${INTERNAL_RULE_ID_KEY}:rule-1`, - `${INTERNAL_IMMUTABLE_KEY}:true`, - ], - throttle: 'no_actions', - }); - const expected: Partial = { - actions: [], - author: ['Elastic'], - building_block_type: 'default', - created_by: 'elastic', - description: 'Detecting root and admin users', - enabled: false, - false_positives: [], - from: 'now-6m', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: 'some interval', - language: 'kuery', - license: 'Elastic License', - max_signals: 10000, - name: 'some-name', - output_index: '.siem-signals', - query: 'user.name: root or user.name: admin', - references: ['http://google.com'], - risk_score: 50, - risk_score_mapping: [], - rule_id: 'rule-1', - severity: 'high', - severity_mapping: [], - tags: ['some fake tag 1', 'some fake tag 2'], - threat: [], - to: 'now', - type: 'query', - note: '', - updated_by: 'elastic', - updated_at: rule.updated_at, - created_at: rule.created_at, - throttle: 'no_actions', - exceptions_list: getListArrayMock(), - version: 1, - }; - expect(rule).toEqual(expected); + ruleSO.attributes.params.filters = ruleFilters; + const rule = buildRuleWithoutOverrides(ruleSO); + expect(rule.filters).toEqual(ruleFilters); }); test('it creates a indicator/threat_mapping/threat_matching rule', () => { - const ruleParams: RuleTypeParams = { - ...sampleRuleAlertParams(), + const ruleParams: ThreatRuleParams = { + ...getThreatRuleParams(), threatMapping: [ { entries: [ @@ -323,21 +90,8 @@ describe('buildRule', () => { threatIndex: ['threat_index'], threatLanguage: 'kuery', }; - const threatMatchRule = buildRule({ - actions: [], - doc: sampleDocNoSortId(), - ruleParams, - name: 'some-name', - id: sampleRuleGuid, - enabled: false, - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: 'some interval', - tags: [], - throttle: 'no_actions', - }); + const ruleSO = sampleRuleSO(ruleParams); + const threatMatchRule = buildRuleWithoutOverrides(ruleSO); const expected: Partial = { threat_mapping: ruleParams.threatMapping, threat_filters: ruleParams.threatFilters, @@ -350,106 +104,18 @@ describe('buildRule', () => { }); }); -describe('removeInternalTagsFromRule', () => { - test('it removes internal tags from a typical rule', () => { - const rule = getRulesSchemaMock(); - rule.tags = [ - 'some fake tag 1', - 'some fake tag 2', - `${INTERNAL_RULE_ID_KEY}:rule-1`, - `${INTERNAL_IMMUTABLE_KEY}:true`, - ]; - const noInternals = removeInternalTagsFromRule(rule); - expect(noInternals).toEqual(getRulesSchemaMock()); - }); - - test('it works with an empty array', () => { - const rule = getRulesSchemaMock(); - rule.tags = []; - const noInternals = removeInternalTagsFromRule(rule); - const expected = getRulesSchemaMock(); - expected.tags = []; - expect(noInternals).toEqual(expected); - }); - - test('it works if tags contains normal values and no internal values', () => { - const rule = getRulesSchemaMock(); - const noInternals = removeInternalTagsFromRule(rule); - expect(noInternals).toEqual(rule); - }); -}); - -describe('buildRuleWithoutOverrides', () => { - test('builds a rule using rule SO', () => { - const ruleSO = sampleRuleSO(); - const rule = buildRuleWithoutOverrides(ruleSO); - expect(rule).toEqual(expectedRule()); - }); - - test('builds a rule using rule SO and removes internal tags', () => { - const ruleSO = sampleRuleSO(); - ruleSO.attributes.tags = [ - 'some fake tag 1', - 'some fake tag 2', - `${INTERNAL_RULE_ID_KEY}:rule-1`, - `${INTERNAL_IMMUTABLE_KEY}:true`, - ]; - const rule = buildRuleWithoutOverrides(ruleSO); - expect(rule).toEqual(expectedRule()); - }); -}); - describe('buildRuleWithOverrides', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - test('it builds a rule as expected with filters present', () => { - const ruleSO = sampleRuleSO(); - ruleSO.attributes.params.filters = [ - { - query: 'host.name: Rebecca', - }, - { - query: 'host.name: Evan', - }, - { - query: 'host.name: Braden', - }, - ]; - // @ts-expect-error @elastic/elasticsearch _source is optional - const rule = buildRuleWithOverrides(ruleSO, sampleDocNoSortId()._source); - const expected: RulesSchema = { - ...expectedRule(), - filters: ruleSO.attributes.params.filters, - }; - expect(rule).toEqual(expected); - }); - - test('it builds a rule and removes internal tags', () => { - const ruleSO = sampleRuleSO(); - ruleSO.attributes.tags = [ - 'some fake tag 1', - 'some fake tag 2', - `${INTERNAL_RULE_ID_KEY}:rule-1`, - `${INTERNAL_IMMUTABLE_KEY}:true`, - ]; - // @ts-expect-error @elastic/elasticsearch _source is optional - const rule = buildRuleWithOverrides(ruleSO, sampleDocNoSortId()._source); - expect(rule).toEqual(expectedRule()); - }); - test('it applies rule name override in buildRule', () => { - const ruleSO = sampleRuleSO(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); ruleSO.attributes.params.ruleNameOverride = 'someKey'; - // @ts-expect-error @elastic/elasticsearch _source is optional - const rule = buildRuleWithOverrides(ruleSO, sampleDocNoSortId()._source); + const rule = buildRuleWithOverrides(ruleSO, sampleDocNoSortId()._source!); const expected = { ...expectedRule(), name: 'someValue', rule_name_override: 'someKey', meta: { ruleNameOverridden: true, + someMeta: 'someField', }, }; expect(rule).toEqual(expected); @@ -457,7 +123,7 @@ describe('buildRuleWithOverrides', () => { test('it applies risk score override in buildRule', () => { const newRiskScore = 79; - const ruleSO = sampleRuleSO(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); ruleSO.attributes.params.riskScoreMapping = [ { field: 'new_risk_score', @@ -470,14 +136,14 @@ describe('buildRuleWithOverrides', () => { const doc = sampleDocNoSortId(); // @ts-expect-error @elastic/elasticsearch _source is optional doc._source.new_risk_score = newRiskScore; - // @ts-expect-error @elastic/elasticsearch _source is optional - const rule = buildRuleWithOverrides(ruleSO, doc._source); + const rule = buildRuleWithOverrides(ruleSO, doc._source!); const expected = { ...expectedRule(), risk_score: newRiskScore, risk_score_mapping: ruleSO.attributes.params.riskScoreMapping, meta: { riskScoreOverridden: true, + someMeta: 'someField', }, }; expect(rule).toEqual(expected); @@ -485,7 +151,7 @@ describe('buildRuleWithOverrides', () => { test('it applies severity override in buildRule', () => { const eventSeverity = '42'; - const ruleSO = sampleRuleSO(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); ruleSO.attributes.params.severityMapping = [ { field: 'event.severity', @@ -495,14 +161,14 @@ describe('buildRuleWithOverrides', () => { }, ]; const doc = sampleDocSeverity(Number(eventSeverity)); - // @ts-expect-error @elastic/elasticsearch _source is optional - const rule = buildRuleWithOverrides(ruleSO, doc._source); + const rule = buildRuleWithOverrides(ruleSO, doc._source!); const expected = { ...expectedRule(), severity: 'critical', severity_mapping: ruleSO.attributes.params.severityMapping, meta: { severityOverrideField: 'event.severity', + someMeta: 'someField', }, }; expect(rule).toEqual(expected); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.ts index 7755f2af70d844..55f22188a7ec85 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.ts @@ -7,202 +7,35 @@ import { SavedObject } from 'src/core/types'; import { RulesSchema } from '../../../../common/detection_engine/schemas/response/rules_schema'; -import { RuleAlertAction } from '../../../../common/detection_engine/types'; -import { RuleTypeParams } from '../types'; import { buildRiskScoreFromMapping } from './mappings/build_risk_score_from_mapping'; -import { SignalSourceHit, RuleAlertAttributes, SignalSource } from './types'; +import { AlertAttributes, SignalSource } from './types'; import { buildSeverityFromMapping } from './mappings/build_severity_from_mapping'; import { buildRuleNameFromMapping } from './mappings/build_rule_name_from_mapping'; -import { INTERNAL_IDENTIFIER } from '../../../../common/constants'; +import { RuleParams } from '../schemas/rule_schemas'; +import { commonParamsCamelToSnake, typeSpecificCamelToSnake } from '../schemas/rule_converters'; +import { transformTags } from '../routes/rules/utils'; -interface BuildRuleParams { - ruleParams: RuleTypeParams; - name: string; - id: string; - actions: RuleAlertAction[]; - enabled: boolean; - createdAt: string; - createdBy: string; - doc: SignalSourceHit; - updatedAt: string; - updatedBy: string; - interval: string; - tags: string[]; - throttle: string; -} - -export const buildRule = ({ - ruleParams, - name, - id, - actions, - enabled, - createdAt, - createdBy, - doc, - updatedAt, - updatedBy, - interval, - tags, - throttle, -}: BuildRuleParams): RulesSchema => { - const { riskScore, riskScoreMeta } = buildRiskScoreFromMapping({ - // @ts-expect-error @elastic/elasticsearch _source is optional - eventSource: doc._source, - riskScore: ruleParams.riskScore, - riskScoreMapping: ruleParams.riskScoreMapping, - }); - - const { severity, severityMeta } = buildSeverityFromMapping({ - // @ts-expect-error @elastic/elasticsearch _source is optional - eventSource: doc._source, - severity: ruleParams.severity, - severityMapping: ruleParams.severityMapping, - }); - - const { ruleName, ruleNameMeta } = buildRuleNameFromMapping({ - // @ts-expect-error @elastic/elasticsearch _source is optional - eventSource: doc._source, - ruleName: name, - ruleNameMapping: ruleParams.ruleNameOverride, - }); - - const meta: RulesSchema['meta'] = { - ...ruleParams.meta, - ...riskScoreMeta, - ...severityMeta, - ...ruleNameMeta, - }; - - const rule: RulesSchema = { - id, - rule_id: ruleParams.ruleId ?? '(unknown rule_id)', - actions, - author: ruleParams.author ?? [], - building_block_type: ruleParams.buildingBlockType, - false_positives: ruleParams.falsePositives, - saved_id: ruleParams.savedId, - timeline_id: ruleParams.timelineId, - timeline_title: ruleParams.timelineTitle, - meta: Object.keys(meta).length > 0 ? meta : undefined, - max_signals: ruleParams.maxSignals, - risk_score: riskScore, - risk_score_mapping: ruleParams.riskScoreMapping ?? [], - output_index: ruleParams.outputIndex, - description: ruleParams.description, - note: ruleParams.note, - from: ruleParams.from, - immutable: ruleParams.immutable, - index: ruleParams.index, - interval, - language: ruleParams.language, - license: ruleParams.license, - name: ruleName, - query: ruleParams.query, - references: ruleParams.references, - rule_name_override: ruleParams.ruleNameOverride, - severity, - severity_mapping: ruleParams.severityMapping ?? [], - tags, - type: ruleParams.type, - to: ruleParams.to, - enabled, - filters: ruleParams.filters, - created_by: createdBy, - updated_by: updatedBy, - threat: ruleParams.threat ?? [], - threat_mapping: ruleParams.threatMapping, - threat_filters: ruleParams.threatFilters, - threat_indicator_path: ruleParams.threatIndicatorPath, - threat_query: ruleParams.threatQuery, - threat_index: ruleParams.threatIndex, - threat_language: ruleParams.threatLanguage, - timestamp_override: ruleParams.timestampOverride, - throttle, - version: ruleParams.version, - created_at: createdAt, - updated_at: updatedAt, - exceptions_list: ruleParams.exceptionsList ?? [], - machine_learning_job_id: ruleParams.machineLearningJobId, - anomaly_threshold: ruleParams.anomalyThreshold, - threshold: ruleParams.threshold, - }; - return removeInternalTagsFromRule(rule); -}; - -export const buildRuleWithoutOverrides = ( - ruleSO: SavedObject -): RulesSchema => { +export const buildRuleWithoutOverrides = (ruleSO: SavedObject): RulesSchema => { const ruleParams = ruleSO.attributes.params; - const rule: RulesSchema = { + return { id: ruleSO.id, - rule_id: ruleParams.ruleId, actions: ruleSO.attributes.actions, - author: ruleParams.author ?? [], - building_block_type: ruleParams.buildingBlockType, - false_positives: ruleParams.falsePositives, - saved_id: ruleParams.savedId, - timeline_id: ruleParams.timelineId, - timeline_title: ruleParams.timelineTitle, - meta: ruleParams.meta, - max_signals: ruleParams.maxSignals, - risk_score: ruleParams.riskScore, - risk_score_mapping: [], - output_index: ruleParams.outputIndex, - description: ruleParams.description, - note: ruleParams.note, - from: ruleParams.from, - immutable: ruleParams.immutable, - index: ruleParams.index, interval: ruleSO.attributes.schedule.interval, - language: ruleParams.language, - license: ruleParams.license, name: ruleSO.attributes.name, - query: ruleParams.query, - references: ruleParams.references, - severity: ruleParams.severity, - severity_mapping: [], - tags: ruleSO.attributes.tags, - type: ruleParams.type, - to: ruleParams.to, + tags: transformTags(ruleSO.attributes.tags), enabled: ruleSO.attributes.enabled, - filters: ruleParams.filters, created_by: ruleSO.attributes.createdBy, updated_by: ruleSO.attributes.updatedBy, - threat: ruleParams.threat ?? [], - timestamp_override: ruleParams.timestampOverride, throttle: ruleSO.attributes.throttle, - version: ruleParams.version, created_at: ruleSO.attributes.createdAt, updated_at: ruleSO.updated_at ?? '', - exceptions_list: ruleParams.exceptionsList ?? [], - machine_learning_job_id: ruleParams.machineLearningJobId, - anomaly_threshold: ruleParams.anomalyThreshold, - threshold: ruleParams.threshold, - threat_filters: ruleParams.threatFilters, - threat_index: ruleParams.threatIndex, - threat_query: ruleParams.threatQuery, - threat_mapping: ruleParams.threatMapping, - threat_language: ruleParams.threatLanguage, - threat_indicator_path: ruleParams.threatIndicatorPath, + ...commonParamsCamelToSnake(ruleParams), + ...typeSpecificCamelToSnake(ruleParams), }; - return removeInternalTagsFromRule(rule); -}; - -export const removeInternalTagsFromRule = (rule: RulesSchema): RulesSchema => { - if (rule.tags == null) { - return rule; - } else { - const ruleWithoutInternalTags: RulesSchema = { - ...rule, - tags: rule.tags.filter((tag) => !tag.startsWith(INTERNAL_IDENTIFIER)), - }; - return ruleWithoutInternalTags; - } }; export const buildRuleWithOverrides = ( - ruleSO: SavedObject, + ruleSO: SavedObject, eventSource: SignalSource ): RulesSchema => { const ruleWithoutOverrides = buildRuleWithoutOverrides(ruleSO); @@ -212,7 +45,7 @@ export const buildRuleWithOverrides = ( export const applyRuleOverrides = ( rule: RulesSchema, eventSource: SignalSource, - ruleParams: RuleTypeParams + ruleParams: RuleParams ): RulesSchema => { const { riskScore, riskScoreMeta } = buildRiskScoreFromMapping({ eventSource, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_ml_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_ml_signals.ts index a5e05d07ee1e17..00ac40fa7e27ce 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_ml_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_ml_signals.ts @@ -8,36 +8,27 @@ import type { estypes } from '@elastic/elasticsearch'; import { flow, omit } from 'lodash/fp'; import set from 'set-value'; -import { Logger } from '../../../../../../../src/core/server'; +import { Logger, SavedObject } from '../../../../../../../src/core/server'; import { AlertInstanceContext, AlertInstanceState, AlertServices, } from '../../../../../alerting/server'; -import { RuleAlertAction } from '../../../../common/detection_engine/types'; -import { RuleTypeParams, RefreshTypes } from '../types'; +import { RefreshTypes } from '../types'; import { singleBulkCreate, SingleBulkCreateResponse } from './single_bulk_create'; import { AnomalyResults, Anomaly } from '../../machine_learning'; import { BuildRuleMessage } from './rule_messages'; +import { AlertAttributes } from './types'; +import { MachineLearningRuleParams } from '../schemas/rule_schemas'; interface BulkCreateMlSignalsParams { - actions: RuleAlertAction[]; someResult: AnomalyResults; - ruleParams: RuleTypeParams; + ruleSO: SavedObject>; services: AlertServices; logger: Logger; id: string; signalsIndex: string; - name: string; - createdAt: string; - createdBy: string; - updatedAt: string; - updatedBy: string; - interval: string; - enabled: boolean; refresh: RefreshTypes; - tags: string[]; - throttle: string; buildRuleMessage: BuildRuleMessage; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts index a4763f67004f63..aa51d133260b8c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts @@ -20,13 +20,14 @@ import { ExceptionListItemSchema } from '../../../../../common/shared_imports'; import { isOutdated } from '../../migrations/helpers'; import { getIndexVersion } from '../../routes/index/get_index_version'; import { MIN_EQL_RULE_INDEX_VERSION } from '../../routes/index/get_signals_template'; +import { EqlRuleParams } from '../../schemas/rule_schemas'; import { RefreshTypes } from '../../types'; import { buildSignalFromEvent, buildSignalGroupFromSequence } from '../build_bulk_body'; import { getInputIndex } from '../get_input_output_index'; import { RuleStatusService } from '../rule_status_service'; import { bulkInsertSignals, filterDuplicateSignals } from '../single_bulk_create'; import { - EqlRuleAttributes, + AlertAttributes, EqlSignalSearchResponse, SearchAfterAndBulkCreateReturnType, WrappedSignalHit, @@ -43,7 +44,7 @@ export const eqlExecutor = async ({ logger, refresh, }: { - rule: SavedObject; + rule: SavedObject>; exceptionItems: ExceptionListItemSchema[]; ruleStatusService: RuleStatusService; services: AlertServices; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/ml.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/ml.ts index 12ebca1aa3e7cf..338ad2dbe9d400 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/ml.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/ml.ts @@ -16,13 +16,14 @@ import { ListClient } from '../../../../../../lists/server'; import { isJobStarted } from '../../../../../common/machine_learning/helpers'; import { ExceptionListItemSchema } from '../../../../../common/shared_imports'; import { SetupPlugins } from '../../../../plugin'; +import { MachineLearningRuleParams } from '../../schemas/rule_schemas'; import { RefreshTypes } from '../../types'; import { bulkCreateMlSignals } from '../bulk_create_ml_signals'; import { filterEventsAgainstList } from '../filters/filter_events_against_list'; import { findMlSignals } from '../find_ml_signals'; import { BuildRuleMessage } from '../rule_messages'; import { RuleStatusService } from '../rule_status_service'; -import { MachineLearningRuleAttributes } from '../types'; +import { AlertAttributes } from '../types'; import { createErrorsFromShard, createSearchAfterReturnType, mergeReturns } from '../utils'; export const mlExecutor = async ({ @@ -36,7 +37,7 @@ export const mlExecutor = async ({ refresh, buildRuleMessage, }: { - rule: SavedObject; + rule: SavedObject>; ml: SetupPlugins['ml']; listClient: ListClient; exceptionItems: ExceptionListItemSchema[]; @@ -105,23 +106,13 @@ export const mlExecutor = async ({ createdItemsCount, createdItems, } = await bulkCreateMlSignals({ - actions: rule.attributes.actions, - throttle: rule.attributes.throttle, someResult: filteredAnomalyResults, - ruleParams, + ruleSO: rule, services, logger, id: rule.id, signalsIndex: ruleParams.outputIndex, - name: rule.attributes.name, - createdBy: rule.attributes.createdBy, - createdAt: rule.attributes.createdAt, - updatedBy: rule.attributes.updatedBy, - updatedAt: rule.updated_at ?? '', - interval: rule.attributes.schedule.interval, - enabled: rule.attributes.enabled, refresh, - tags: rule.attributes.tags, buildRuleMessage, }); // The legacy ES client does not define failures when it can be present on the structure, hence why I have the & { failures: [] } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts index 9914eb04c6ca68..751a1fa0817525 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts @@ -18,9 +18,10 @@ import { RefreshTypes } from '../../types'; import { getFilter } from '../get_filter'; import { getInputIndex } from '../get_input_output_index'; import { searchAfterAndBulkCreate } from '../search_after_bulk_create'; -import { QueryRuleAttributes, RuleRangeTuple } from '../types'; +import { AlertAttributes, RuleRangeTuple } from '../types'; import { TelemetryEventsSender } from '../../../telemetry/sender'; import { BuildRuleMessage } from '../rule_messages'; +import { QueryRuleParams } from '../../schemas/rule_schemas'; export const queryExecutor = async ({ rule, @@ -35,7 +36,7 @@ export const queryExecutor = async ({ eventsTelemetry, buildRuleMessage, }: { - rule: SavedObject; + rule: SavedObject>; tuples: RuleRangeTuple[]; listClient: ListClient; exceptionItems: ExceptionListItemSchema[]; @@ -64,7 +65,7 @@ export const queryExecutor = async ({ tuples, listClient, exceptionsList: exceptionItems, - ruleParams, + ruleSO: rule, services, logger, eventsTelemetry, @@ -72,18 +73,8 @@ export const queryExecutor = async ({ inputIndexPattern: inputIndex, signalsIndex: ruleParams.outputIndex, filter: esFilter, - actions: rule.attributes.actions, - name: rule.attributes.name, - createdBy: rule.attributes.createdBy, - createdAt: rule.attributes.createdAt, - updatedBy: rule.attributes.updatedBy, - updatedAt: rule.updated_at ?? '', - interval: rule.attributes.schedule.interval, - enabled: rule.attributes.enabled, pageSize: searchAfterSize, refresh, - tags: rule.attributes.tags, - throttle: rule.attributes.throttle, buildRuleMessage, }); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threat_match.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threat_match.ts index 5a8e945c3b06e1..62619cf948d401 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threat_match.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threat_match.ts @@ -16,10 +16,11 @@ import { ListClient } from '../../../../../../lists/server'; import { ExceptionListItemSchema } from '../../../../../common/shared_imports'; import { RefreshTypes } from '../../types'; import { getInputIndex } from '../get_input_output_index'; -import { RuleRangeTuple, ThreatRuleAttributes } from '../types'; +import { RuleRangeTuple, AlertAttributes } from '../types'; import { TelemetryEventsSender } from '../../../telemetry/sender'; import { BuildRuleMessage } from '../rule_messages'; import { createThreatSignals } from '../threat_mapping/create_threat_signals'; +import { ThreatRuleParams } from '../../schemas/rule_schemas'; export const threatMatchExecutor = async ({ rule, @@ -34,7 +35,7 @@ export const threatMatchExecutor = async ({ eventsTelemetry, buildRuleMessage, }: { - rule: SavedObject; + rule: SavedObject>; tuples: RuleRangeTuple[]; listClient: ListClient; exceptionItems: ExceptionListItemSchema[]; @@ -56,7 +57,6 @@ export const threatMatchExecutor = async ({ type: ruleParams.type, filters: ruleParams.filters ?? [], language: ruleParams.language, - name: rule.attributes.name, savedId: ruleParams.savedId, services, exceptionItems, @@ -65,18 +65,9 @@ export const threatMatchExecutor = async ({ eventsTelemetry, alertId: rule.id, outputIndex: ruleParams.outputIndex, - params: ruleParams, + ruleSO: rule, searchAfterSize, - actions: rule.attributes.actions, - createdBy: rule.attributes.createdBy, - createdAt: rule.attributes.createdAt, - updatedBy: rule.attributes.updatedBy, - interval: rule.attributes.schedule.interval, - updatedAt: rule.updated_at ?? '', - enabled: rule.attributes.enabled, refresh, - tags: rule.attributes.tags, - throttle: rule.attributes.throttle, threatFilters: ruleParams.threatFilters ?? [], threatQuery: ruleParams.threatQuery, threatLanguage: ruleParams.threatLanguage, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.ts index c8f70449251f68..204481f5d910cb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.ts @@ -12,11 +12,9 @@ import { AlertInstanceState, AlertServices, } from '../../../../../../alerting/server'; -import { - hasLargeValueItem, - normalizeThresholdField, -} from '../../../../../common/detection_engine/utils'; +import { hasLargeValueItem } from '../../../../../common/detection_engine/utils'; import { ExceptionListItemSchema } from '../../../../../common/shared_imports'; +import { ThresholdRuleParams } from '../../schemas/rule_schemas'; import { RefreshTypes } from '../../types'; import { getFilter } from '../get_filter'; import { getInputIndex } from '../get_input_output_index'; @@ -28,11 +26,7 @@ import { getThresholdBucketFilters, getThresholdSignalHistory, } from '../threshold'; -import { - RuleRangeTuple, - SearchAfterAndBulkCreateReturnType, - ThresholdRuleAttributes, -} from '../types'; +import { AlertAttributes, RuleRangeTuple, SearchAfterAndBulkCreateReturnType } from '../types'; import { createSearchAfterReturnType, createSearchAfterReturnTypeFromResponse, @@ -51,7 +45,7 @@ export const thresholdExecutor = async ({ buildRuleMessage, startedAt, }: { - rule: SavedObject; + rule: SavedObject>; tuples: RuleRangeTuple[]; exceptionItems: ExceptionListItemSchema[]; ruleStatusService: RuleStatusService; @@ -83,7 +77,7 @@ export const thresholdExecutor = async ({ services, logger, ruleId: ruleParams.ruleId, - bucketByFields: normalizeThresholdField(ruleParams.threshold.field), + bucketByFields: ruleParams.threshold.field, timestampOverride: ruleParams.timestampOverride, buildRuleMessage, }); @@ -127,28 +121,17 @@ export const thresholdExecutor = async ({ createdItems, errors, } = await bulkCreateThresholdSignals({ - actions: rule.attributes.actions, - throttle: rule.attributes.throttle, someResult: thresholdResults, - ruleParams, + ruleSO: rule, filter: esFilter, services, logger, id: rule.id, inputIndexPattern: inputIndex, signalsIndex: ruleParams.outputIndex, - timestampOverride: ruleParams.timestampOverride, startedAt, from: tuple.from.toDate(), - name: rule.attributes.name, - createdBy: rule.attributes.createdBy, - createdAt: rule.attributes.createdAt, - updatedBy: rule.attributes.updatedBy, - updatedAt: rule.updated_at ?? '', - interval: rule.attributes.schedule.interval, - enabled: rule.attributes.enabled, refresh, - tags: rule.attributes.tags, thresholdSignalHistory, buildRuleMessage, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts index 6deb45095ec360..9d9eefe8445326 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts @@ -6,9 +6,9 @@ */ import { - sampleRuleAlertParams, sampleEmptyDocSearchResults, sampleRuleGuid, + sampleRuleSO, mockLogger, repeatedSearchResultsWithSortId, repeatedSearchResultsWithNoSortId, @@ -28,6 +28,7 @@ import { getSearchListItemResponseMock } from '../../../../../lists/common/schem import { getRuleRangeTuples } from './utils'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; +import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; const buildRuleMessage = buildRuleMessageFactory({ id: 'fake id', @@ -41,7 +42,9 @@ describe('searchAfterAndBulkCreate', () => { let inputIndexPattern: string[] = []; let listClient = listMock.getListClient(); const someGuids = Array.from({ length: 13 }).map(() => uuid.v4()); - const sampleParams = sampleRuleAlertParams(30); + const sampleParams = getQueryRuleParams(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); + sampleParams.maxSignals = 30; let tuples: RuleRangeTuple[]; beforeEach(() => { jest.clearAllMocks(); @@ -164,8 +167,8 @@ describe('searchAfterAndBulkCreate', () => { }, ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ - ruleParams: sampleParams, tuples, + ruleSO, listClient, exceptionsList: [exceptionItem], services: mockService, @@ -174,19 +177,9 @@ describe('searchAfterAndBulkCreate', () => { id: sampleRuleGuid, inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, pageSize: 1, filter: undefined, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(true); @@ -277,7 +270,7 @@ describe('searchAfterAndBulkCreate', () => { }, ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ - ruleParams: sampleParams, + ruleSO, tuples, listClient, exceptionsList: [exceptionItem], @@ -287,19 +280,9 @@ describe('searchAfterAndBulkCreate', () => { id: sampleRuleGuid, inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, pageSize: 1, filter: undefined, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(true); @@ -364,7 +347,7 @@ describe('searchAfterAndBulkCreate', () => { }, ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ - ruleParams: sampleParams, + ruleSO, tuples, listClient, exceptionsList: [exceptionItem], @@ -374,19 +357,9 @@ describe('searchAfterAndBulkCreate', () => { id: sampleRuleGuid, inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, pageSize: 1, filter: undefined, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(true); @@ -432,7 +405,7 @@ describe('searchAfterAndBulkCreate', () => { }, ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ - ruleParams: sampleParams, + ruleSO, tuples, listClient, exceptionsList: [exceptionItem], @@ -442,19 +415,9 @@ describe('searchAfterAndBulkCreate', () => { id: sampleRuleGuid, inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, pageSize: 1, filter: undefined, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(true); @@ -496,7 +459,7 @@ describe('searchAfterAndBulkCreate', () => { }, ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ - ruleParams: sampleParams, + ruleSO, tuples, listClient, exceptionsList: [exceptionItem], @@ -506,19 +469,9 @@ describe('searchAfterAndBulkCreate', () => { id: sampleRuleGuid, inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, pageSize: 1, filter: undefined, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(true); @@ -582,7 +535,7 @@ describe('searchAfterAndBulkCreate', () => { }, ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ - ruleParams: sampleParams, + ruleSO, tuples, listClient, exceptionsList: [exceptionItem], @@ -592,19 +545,9 @@ describe('searchAfterAndBulkCreate', () => { id: sampleRuleGuid, inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, pageSize: 1, filter: undefined, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(true); @@ -670,7 +613,7 @@ describe('searchAfterAndBulkCreate', () => { ) ); const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ - ruleParams: sampleParams, + ruleSO, tuples, listClient, exceptionsList: [], @@ -680,19 +623,9 @@ describe('searchAfterAndBulkCreate', () => { id: sampleRuleGuid, inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, pageSize: 1, filter: undefined, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(true); @@ -726,26 +659,16 @@ describe('searchAfterAndBulkCreate', () => { listClient, exceptionsList: [exceptionItem], tuples, - ruleParams: sampleParams, + ruleSO, services: mockService, logger: mockLogger, eventsTelemetry: undefined, id: sampleRuleGuid, inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, pageSize: 1, filter: undefined, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(mockLogger.error).toHaveBeenCalled(); @@ -782,26 +705,16 @@ describe('searchAfterAndBulkCreate', () => { listClient, exceptionsList: [exceptionItem], tuples, - ruleParams: sampleParams, + ruleSO, services: mockService, logger: mockLogger, eventsTelemetry: undefined, id: sampleRuleGuid, inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, pageSize: 1, filter: undefined, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(true); @@ -852,26 +765,16 @@ describe('searchAfterAndBulkCreate', () => { listClient, exceptionsList: [exceptionItem], tuples, - ruleParams: sampleParams, + ruleSO, services: mockService, logger: mockLogger, eventsTelemetry: undefined, id: sampleRuleGuid, inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, pageSize: 1, filter: undefined, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(false); @@ -984,7 +887,7 @@ describe('searchAfterAndBulkCreate', () => { lastLookBackDate, errors, } = await searchAfterAndBulkCreate({ - ruleParams: sampleParams, + ruleSO, tuples, listClient, exceptionsList: [], @@ -994,19 +897,9 @@ describe('searchAfterAndBulkCreate', () => { id: sampleRuleGuid, inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, pageSize: 1, filter: undefined, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(false); @@ -1089,7 +982,7 @@ describe('searchAfterAndBulkCreate', () => { const mockEnrichment = jest.fn((a) => a); const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ enrichment: mockEnrichment, - ruleParams: sampleParams, + ruleSO, tuples, listClient, exceptionsList: [], @@ -1099,19 +992,9 @@ describe('searchAfterAndBulkCreate', () => { id: sampleRuleGuid, inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, pageSize: 1, filter: undefined, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts index cfe30a66023813..0bc0039b54dba7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts @@ -25,7 +25,7 @@ import { SearchAfterAndBulkCreateParams, SearchAfterAndBulkCreateReturnType } fr // search_after through documents and re-index using bulk endpoint. export const searchAfterAndBulkCreate = async ({ tuples: totalToFromTuples, - ruleParams, + ruleSO, exceptionsList, services, listClient, @@ -35,21 +35,12 @@ export const searchAfterAndBulkCreate = async ({ inputIndexPattern, signalsIndex, filter, - actions, - name, - createdAt, - createdBy, - updatedBy, - updatedAt, - interval, - enabled, pageSize, refresh, - tags, - throttle, buildRuleMessage, enrichment = identity, }: SearchAfterAndBulkCreateParams): Promise => { + const ruleParams = ruleSO.attributes.params; let toReturn = createSearchAfterReturnType(); // sortId tells us where to start our next consecutive search_after query @@ -218,22 +209,12 @@ export const searchAfterAndBulkCreate = async ({ } = await singleBulkCreate({ buildRuleMessage, filteredEvents: enrichedEvents, - ruleParams, + ruleSO, services, logger, id, signalsIndex, - actions, - name, - createdAt, - createdBy, - updatedAt, - updatedBy, - interval, - enabled, refresh, - tags, - throttle, }); toReturn = mergeReturns([ toReturn, @@ -252,13 +233,7 @@ export const searchAfterAndBulkCreate = async ({ buildRuleMessage(`filteredEvents.hits.hits: ${filteredEvents.hits.hits.length}`) ); - sendAlertTelemetryEvents( - logger, - eventsTelemetry, - filteredEvents, - ruleParams, - buildRuleMessage - ); + sendAlertTelemetryEvents(logger, eventsTelemetry, filteredEvents, buildRuleMessage); } if (!hasSortId && !hasBackupSortId) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/send_telemetry_events.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/send_telemetry_events.ts index f7d21adc4bea97..d87427576cd8f3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/send_telemetry_events.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/send_telemetry_events.ts @@ -6,7 +6,6 @@ */ import { TelemetryEventsSender, TelemetryEvent } from '../../telemetry/sender'; -import { RuleTypeParams } from '../types'; import { BuildRuleMessage } from './rule_messages'; import { SignalSearchResponse, SignalSource } from './types'; import { Logger } from '../../../../../../../src/core/server'; @@ -31,7 +30,6 @@ export function sendAlertTelemetryEvents( logger: Logger, eventsTelemetry: TelemetryEventsSender | undefined, filteredEvents: SignalSearchResponse, - ruleParams: RuleTypeParams, buildRuleMessage: BuildRuleMessage ) { if (eventsTelemetry === undefined) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.mock.ts deleted file mode 100644 index d1cab7397bbfd6..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.mock.ts +++ /dev/null @@ -1,55 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { SignalParamsSchema } from './signal_params_schema'; - -export const getSignalParamsSchemaMock = (): Partial => ({ - description: 'Detecting root and admin users', - query: 'user.name: root or user.name: admin', - severity: 'high', - type: 'query', - riskScore: 55, - language: 'kuery', - ruleId: 'rule-1', - from: 'now-6m', - to: 'now', -}); - -export const getSignalParamsSchemaDecodedMock = (): SignalParamsSchema => ({ - author: [], - buildingBlockType: null, - description: 'Detecting root and admin users', - eventCategoryOverride: undefined, - falsePositives: [], - filters: null, - from: 'now-6m', - immutable: false, - index: null, - language: 'kuery', - license: null, - maxSignals: 100, - meta: null, - note: null, - outputIndex: null, - query: 'user.name: root or user.name: admin', - references: [], - riskScore: 55, - riskScoreMapping: null, - ruleNameOverride: null, - ruleId: 'rule-1', - savedId: null, - severity: 'high', - severityMapping: null, - threatFilters: null, - threat: null, - timelineId: null, - timelineTitle: null, - timestampOverride: null, - to: 'now', - type: 'query', - version: 1, -}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.test.ts deleted file mode 100644 index 21db1e55b9810e..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.test.ts +++ /dev/null @@ -1,158 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { signalParamsSchema, SignalParamsSchema } from './signal_params_schema'; -import { - getSignalParamsSchemaDecodedMock, - getSignalParamsSchemaMock, -} from './signal_params_schema.mock'; -import { DEFAULT_MAX_SIGNALS } from '../../../../common/constants'; - -describe('signal_params_schema', () => { - test('it works with expected basic mock data set', () => { - const schema = signalParamsSchema(); - expect(schema.validate(getSignalParamsSchemaMock())).toEqual( - getSignalParamsSchemaDecodedMock() - ); - }); - - test('it works on older lists data structures if they exist as an empty array', () => { - const schema = signalParamsSchema(); - const mock: Partial = { lists: [], ...getSignalParamsSchemaMock() }; - const expected: Partial = { - lists: [], - ...getSignalParamsSchemaDecodedMock(), - }; - expect(schema.validate(mock)).toEqual(expected); - }); - - test('it works on older exceptions_list data structures if they exist as an empty array', () => { - const schema = signalParamsSchema(); - const mock: Partial = { - exceptions_list: [], - ...getSignalParamsSchemaMock(), - }; - const expected: Partial = { - exceptions_list: [], - ...getSignalParamsSchemaDecodedMock(), - }; - expect(schema.validate(mock)).toEqual(expected); - }); - - test('it throws if given an invalid value', () => { - const schema = signalParamsSchema(); - const mock: Partial & { madeUpValue: string } = { - madeUpValue: 'something', - ...getSignalParamsSchemaMock(), - }; - expect(() => schema.validate(mock)).toThrow( - '[madeUpValue]: definition for this key is missing' - ); - }); - - test('if risk score is a string then it will be converted into a number before being inserted as data', () => { - const schema = signalParamsSchema(); - const mock: Omit, 'riskScore'> & { riskScore: string } = { - ...getSignalParamsSchemaMock(), - riskScore: '5', - }; - expect(schema.validate(mock).riskScore).toEqual(5); - expect(typeof schema.validate(mock).riskScore).toEqual('number'); - }); - - test('if risk score is a number then it will work as a number', () => { - const schema = signalParamsSchema(); - const mock: Partial = { - ...getSignalParamsSchemaMock(), - riskScore: 5, - }; - expect(schema.validate(mock).riskScore).toEqual(5); - expect(typeof schema.validate(mock).riskScore).toEqual('number'); - }); - - test('maxSignals will default to "DEFAULT_MAX_SIGNALS" if not set', () => { - const schema = signalParamsSchema(); - const { maxSignals, ...withoutMockData } = getSignalParamsSchemaMock(); - expect(schema.validate(withoutMockData).maxSignals).toEqual(DEFAULT_MAX_SIGNALS); - }); - - test('version will default to "1" if not set', () => { - const schema = signalParamsSchema(); - const { version, ...withoutVersion } = getSignalParamsSchemaMock(); - expect(schema.validate(withoutVersion).version).toEqual(1); - }); - - test('references will default to an empty array if not set', () => { - const schema = signalParamsSchema(); - const { references, ...withoutReferences } = getSignalParamsSchemaMock(); - expect(schema.validate(withoutReferences).references).toEqual([]); - }); - - test('immutable will default to false if not set', () => { - const schema = signalParamsSchema(); - const { immutable, ...withoutImmutable } = getSignalParamsSchemaMock(); - expect(schema.validate(withoutImmutable).immutable).toEqual(false); - }); - - test('falsePositives will default to an empty array if not set', () => { - const schema = signalParamsSchema(); - const { falsePositives, ...withoutFalsePositives } = getSignalParamsSchemaMock(); - expect(schema.validate(withoutFalsePositives).falsePositives).toEqual([]); - }); - - test('threshold validates with `value` only', () => { - const schema = signalParamsSchema(); - const threshold = { - value: 200, - }; - const mock = { - ...getSignalParamsSchemaMock(), - threshold, - }; - expect(schema.validate(mock).threshold?.value).toEqual(200); - }); - - test('threshold does not validate without `value`', () => { - const schema = signalParamsSchema(); - const threshold = { - field: 'agent.id', - cardinality: [ - { - field: ['host.name'], - value: 5, - }, - ], - }; - const mock = { - ...getSignalParamsSchemaMock(), - threshold, - }; - expect(() => schema.validate(mock)).toThrow(); - }); - - test('threshold `cardinality` cannot currently be greater than length 1', () => { - const schema = signalParamsSchema(); - const threshold = { - value: 100, - cardinality: [ - { - field: 'host.name', - value: 5, - }, - { - field: 'user.name', - value: 5, - }, - ], - }; - const mock = { - ...getSignalParamsSchemaMock(), - threshold, - }; - expect(() => schema.validate(mock)).toThrow(); - }); -}); 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 deleted file mode 100644 index fe4781d3843582..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.ts +++ /dev/null @@ -1,86 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { schema, TypeOf } from '@kbn/config-schema'; - -import { DEFAULT_MAX_SIGNALS } from '../../../../common/constants'; - -export const signalSchema = schema.object({ - anomalyThreshold: schema.maybe(schema.number()), - author: schema.arrayOf(schema.string(), { defaultValue: [] }), - buildingBlockType: schema.nullable(schema.string()), - description: schema.string(), - note: schema.nullable(schema.string()), - eventCategoryOverride: schema.maybe(schema.string()), - falsePositives: schema.arrayOf(schema.string(), { defaultValue: [] }), - from: schema.string(), - ruleId: schema.string(), - immutable: schema.boolean({ defaultValue: false }), - index: schema.nullable(schema.arrayOf(schema.string())), - language: schema.nullable(schema.string()), - license: schema.nullable(schema.string()), - outputIndex: schema.nullable(schema.string()), - savedId: schema.nullable(schema.string()), - timelineId: schema.nullable(schema.string()), - timelineTitle: schema.nullable(schema.string()), - meta: schema.nullable(schema.object({}, { unknowns: 'allow' })), - machineLearningJobId: schema.maybe(schema.string()), - query: schema.nullable(schema.string()), - filters: schema.nullable(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), - maxSignals: schema.number({ defaultValue: DEFAULT_MAX_SIGNALS }), - riskScore: schema.number(), - // TODO: Specify types explicitly since they're known? - riskScoreMapping: schema.nullable(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), - ruleNameOverride: schema.nullable(schema.string()), - severity: schema.string(), - severityMapping: schema.nullable(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), - threat: schema.nullable(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), - threshold: schema.maybe( - schema.object({ - // Can be an empty string (pre-7.12) or empty array (7.12+) - field: schema.nullable( - schema.oneOf([schema.string(), schema.arrayOf(schema.string(), { maxSize: 3 })]) - ), - // Always required - value: schema.number(), - // Can be null (pre-7.12) or empty array (7.12+) - cardinality: schema.nullable( - schema.arrayOf( - schema.object({ - field: schema.string(), - value: schema.number(), - }), - { maxSize: 1 } - ) - ), - }) - ), - timestampOverride: schema.nullable(schema.string()), - to: schema.string(), - type: schema.string(), - references: schema.arrayOf(schema.string(), { defaultValue: [] }), - version: schema.number({ defaultValue: 1 }), - lists: schema.maybe(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), // For backwards compatibility with customers that had a data bug in 7.7. Once we use a migration script please remove this. - exceptions_list: schema.maybe(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), // For backwards compatibility with customers that had a data bug in 7.8. Once we use a migration script please remove this. - exceptionsList: schema.maybe(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), - threatFilters: schema.nullable(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), - threatIndex: schema.maybe(schema.arrayOf(schema.string())), - threatIndicatorPath: schema.maybe(schema.string()), - 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()), -}); - -/** - * This is the schema for the Alert Rule that represents the SIEM alert for signals - * that index into the .siem-signals-${space-id} - */ -export const signalParamsSchema = () => signalSchema; - -export type SignalParamsSchema = TypeOf; 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 ba7776af9d36aa..ae58909d727de3 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 @@ -8,7 +8,7 @@ import moment from 'moment'; import type { estypes } from '@elastic/elasticsearch'; import { loggingSystemMock } from 'src/core/server/mocks'; -import { getResult, getMlResult } from '../routes/__mocks__/request_responses'; +import { getAlertMock } from '../routes/__mocks__/request_responses'; import { signalRulesAlertType } from './signal_rule_alert_type'; import { alertsMock, AlertServicesMock } from '../../../../../alerting/server/mocks'; import { ruleStatusServiceFactory } from './rule_status_service'; @@ -31,6 +31,7 @@ import { ApiResponse } from '@elastic/elasticsearch/lib/Transport'; import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; import { queryExecutor } from './executors/query'; import { mlExecutor } from './executors/ml'; +import { getMlRuleParams, getQueryRuleParams } from '../schemas/rule_schemas.mock'; jest.mock('./rule_status_saved_objects_client'); jest.mock('./rule_status_service'); @@ -54,19 +55,13 @@ const getPayload = ( ): RuleExecutorOptions => ({ alertId: ruleAlert.id, services, + name: ruleAlert.name, + tags: ruleAlert.tags, params: { ...ruleAlert.params, - actions: [], - enabled: ruleAlert.enabled, - interval: ruleAlert.schedule.interval, - name: ruleAlert.name, - tags: ruleAlert.tags, - throttle: ruleAlert.throttle, }, state: {}, spaceId: '', - name: 'name', - tags: [], startedAt: new Date('2019-12-13T16:50:33.400Z'), previousStartedAt: new Date('2019-12-13T16:40:33.400Z'), createdBy: 'elastic', @@ -154,7 +149,7 @@ describe('signal_rule_alert_type', () => { alertServices.scopedClusterClient.asCurrentUser.fieldCaps.mockResolvedValue( value as ApiResponse ); - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); alertServices.savedObjectsClient.get.mockResolvedValue({ id: 'id', type: 'type', @@ -208,7 +203,10 @@ describe('signal_rule_alert_type', () => { }, application: {}, }); - payload.params.index = ['some*', 'myfa*', 'anotherindex*']; + const newRuleAlert = getAlertMock(getQueryRuleParams()); + newRuleAlert.params.index = ['some*', 'myfa*', 'anotherindex*']; + payload = getPayload(newRuleAlert, alertServices) as jest.Mocked; + await alert.executor(payload); expect(ruleStatusService.partialFailure).toHaveBeenCalled(); expect(ruleStatusService.partialFailure.mock.calls[0][0]).toContain( @@ -231,7 +229,10 @@ describe('signal_rule_alert_type', () => { }, application: {}, }); - payload.params.index = ['some*', 'myfa*']; + const newRuleAlert = getAlertMock(getQueryRuleParams()); + newRuleAlert.params.index = ['some*', 'myfa*', 'anotherindex*']; + payload = getPayload(newRuleAlert, alertServices) as jest.Mocked; + await alert.executor(payload); expect(ruleStatusService.partialFailure).toHaveBeenCalled(); expect(ruleStatusService.partialFailure.mock.calls[0][0]).toContain( @@ -247,7 +248,7 @@ describe('signal_rule_alert_type', () => { }); it("should set refresh to 'wait_for' when actions are present", async () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); ruleAlert.actions = [ { actionTypeId: '.slack', @@ -276,7 +277,7 @@ describe('signal_rule_alert_type', () => { }); it('should call scheduleActions if signalsCount was greater than 0 and rule has actions defined', async () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); ruleAlert.actions = [ { actionTypeId: '.slack', @@ -306,7 +307,7 @@ describe('signal_rule_alert_type', () => { }); it('should resolve results_link when meta is an empty object to use "/app/security"', async () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); ruleAlert.params.meta = {}; ruleAlert.actions = [ { @@ -343,7 +344,7 @@ describe('signal_rule_alert_type', () => { }); it('should resolve results_link when meta is undefined use "/app/security"', async () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); delete ruleAlert.params.meta; ruleAlert.actions = [ { @@ -380,7 +381,7 @@ describe('signal_rule_alert_type', () => { }); it('should resolve results_link with a custom link', async () => { - const ruleAlert = getResult(); + const ruleAlert = getAlertMock(getQueryRuleParams()); ruleAlert.params.meta = { kibana_siem_app_url: 'http://localhost' }; ruleAlert.actions = [ { @@ -418,7 +419,7 @@ describe('signal_rule_alert_type', () => { describe('ML rule', () => { it('should not call checkPrivileges if ML rule', async () => { - const ruleAlert = getMlResult(); + const ruleAlert = getAlertMock(getMlRuleParams()); alertServices.savedObjectsClient.get.mockResolvedValue({ id: 'id', type: 'type', 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 52ceafbdb69b3c..419141d98d15a9 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 @@ -12,7 +12,6 @@ import { chain, tryCatch } from 'fp-ts/lib/TaskEither'; import { flow } from 'fp-ts/lib/function'; import * as t from 'io-ts'; -import { pickBy } from 'lodash/fp'; import { validateNonExact } from '../../../../common/validate'; import { toError, toPromise } from '../../../../common/fp_utils'; @@ -31,7 +30,7 @@ import { import { parseScheduleDates } from '../../../../common/detection_engine/parse_schedule_dates'; import { SetupPlugins } from '../../../plugin'; import { getInputIndex } from './get_input_output_index'; -import { SignalRuleAlertTypeDefinition, RuleAlertAttributes } from './types'; +import { AlertAttributes, SignalRuleAlertTypeDefinition } from './types'; import { getListsClient, getExceptions, @@ -40,8 +39,8 @@ import { hasTimestampFields, hasReadIndexPrivileges, getRuleRangeTuples, + isMachineLearningParams, } from './utils'; -import { signalParamsSchema } from './signal_params_schema'; import { siemRuleActionGroups } from './siem_rule_action_groups'; import { scheduleNotificationActions, @@ -52,7 +51,6 @@ import { buildRuleMessageFactory } from './rule_messages'; import { ruleStatusSavedObjectsClientFactory } from './rule_status_saved_objects_client'; import { getNotificationResultsLink } from '../notifications/utils'; import { TelemetryEventsSender } from '../../telemetry/sender'; -import { RuleTypeParams } from '../types'; import { eqlExecutor } from './executors/eql'; import { queryExecutor } from './executors/query'; import { threatMatchExecutor } from './executors/threat_match'; @@ -64,6 +62,8 @@ import { queryRuleParams, threatRuleParams, thresholdRuleParams, + ruleParams, + RuleParams, } from '../schemas/rule_schemas'; export const signalRulesAlertType = ({ @@ -85,15 +85,17 @@ export const signalRulesAlertType = ({ actionGroups: siemRuleActionGroups, defaultActionGroupId: 'default', validate: { - /** - * TODO: Fix typing inconsistancy between `RuleTypeParams` and `CreateRulesOptions` - * Once that's done, you should be able to do: - * ``` - * params: signalParamsSchema(), - * ``` - */ - params: (signalParamsSchema() as unknown) as { - validate: (object: unknown) => RuleTypeParams; + params: { + validate: (object: unknown): RuleParams => { + const [validated, errors] = validateNonExact(object, ruleParams); + if (errors != null) { + throw new Error(errors); + } + if (validated == null) { + throw new Error('Validation of rule params failed'); + } + return validated; + }, }, }, producer: SERVER_APP_ID, @@ -107,7 +109,7 @@ export const signalRulesAlertType = ({ spaceId, updatedBy: updatedByUser, }) { - const { ruleId, index, maxSignals, meta, outputIndex, timestampOverride, type } = params; + const { ruleId, maxSignals, meta, outputIndex, timestampOverride, type } = params; const searchAfterSize = Math.min(maxSignals, DEFAULT_SEARCH_AFTER_PAGE_SIZE); let hasError: boolean = false; @@ -117,10 +119,8 @@ export const signalRulesAlertType = ({ alertId, ruleStatusClient, }); - const savedObject = await services.savedObjectsClient.get( - 'alert', - alertId - ); + + const savedObject = await services.savedObjectsClient.get('alert', alertId); const { actions, name, @@ -143,7 +143,8 @@ export const signalRulesAlertType = ({ // move this collection of lines into a function in utils // so that we can use it in create rules route, bulk, etc. try { - if (!isEmpty(index)) { + if (!isMachineLearningParams(params)) { + const index = params.index; const hasTimestampOverride = timestampOverride != null && !isEmpty(timestampOverride); const inputIndices = await getInputIndex(services, version, index); const [privileges, timestampFieldCaps] = await Promise.all([ @@ -392,11 +393,10 @@ export const signalRulesAlertType = ({ * @param schema io-ts schema for the specific rule type the SavedObject claims to be */ export const asTypeSpecificSO = ( - ruleSO: SavedObject, + ruleSO: SavedObject, schema: T ) => { - const nonNullParams = pickBy((value: unknown) => value !== null, ruleSO.attributes.params); - const [validated, errors] = validateNonExact(nonNullParams, schema); + const [validated, errors] = validateNonExact(ruleSO.attributes.params, schema); if (validated == null || errors != null) { throw new Error(`Rule attempted to execute with invalid params: ${errors}`); } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.test.ts index b9a771ac0299ea..3fbb8c1a607e91 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.test.ts @@ -7,7 +7,6 @@ import { generateId } from './utils'; import { - sampleRuleAlertParams, sampleDocSearchResultsNoSortId, mockLogger, sampleRuleGuid, @@ -16,6 +15,7 @@ import { sampleBulkCreateDuplicateResult, sampleBulkCreateErrorResult, sampleDocWithAncestors, + sampleRuleSO, } from './__mocks__/es_results'; import { DEFAULT_SIGNALS_INDEX } from '../../../../common/constants'; import { singleBulkCreate, filterDuplicateRules } from './single_bulk_create'; @@ -23,6 +23,7 @@ import { alertsMock, AlertServicesMock } from '../../../../../alerting/server/mo import { buildRuleMessageFactory } from './rule_messages'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; +import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; const buildRuleMessage = buildRuleMessageFactory({ id: 'fake id', @@ -140,7 +141,7 @@ describe('singleBulkCreate', () => { }); test('create successful bulk create', async () => { - const sampleParams = sampleRuleAlertParams(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValueOnce( // @ts-expect-error not compatible response interface elasticsearchClientMock.createSuccessTransportRequestPromise({ @@ -155,22 +156,12 @@ describe('singleBulkCreate', () => { ); const { success, createdItemsCount } = await singleBulkCreate({ filteredEvents: sampleDocSearchResultsNoSortId(), - ruleParams: sampleParams, + ruleSO, services: mockService, logger: mockLogger, id: sampleRuleGuid, signalsIndex: DEFAULT_SIGNALS_INDEX, - actions: [], - name: 'rule-name', - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(true); @@ -178,7 +169,7 @@ describe('singleBulkCreate', () => { }); test('create successful bulk create with docs with no versioning', async () => { - const sampleParams = sampleRuleAlertParams(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValueOnce( // @ts-expect-error not compatible response interface elasticsearchClientMock.createSuccessTransportRequestPromise({ @@ -193,22 +184,12 @@ describe('singleBulkCreate', () => { ); const { success, createdItemsCount } = await singleBulkCreate({ filteredEvents: sampleDocSearchResultsNoSortIdNoVersion(), - ruleParams: sampleParams, + ruleSO, services: mockService, logger: mockLogger, id: sampleRuleGuid, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(true); @@ -216,29 +197,19 @@ describe('singleBulkCreate', () => { }); test('create unsuccessful bulk create due to empty search results', async () => { - const sampleParams = sampleRuleAlertParams(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValue( // @ts-expect-error not full response interface elasticsearchClientMock.createSuccessTransportRequestPromise(false) ); const { success, createdItemsCount } = await singleBulkCreate({ filteredEvents: sampleEmptyDocSearchResults(), - ruleParams: sampleParams, + ruleSO, services: mockService, logger: mockLogger, id: sampleRuleGuid, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(true); @@ -246,29 +217,18 @@ describe('singleBulkCreate', () => { }); test('create successful bulk create when bulk create has duplicate errors', async () => { - const sampleParams = sampleRuleAlertParams(); - const sampleSearchResult = sampleDocSearchResultsNoSortId; + const ruleSO = sampleRuleSO(getQueryRuleParams()); mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValue( elasticsearchClientMock.createSuccessTransportRequestPromise(sampleBulkCreateDuplicateResult) ); const { success, createdItemsCount } = await singleBulkCreate({ - filteredEvents: sampleSearchResult(), - ruleParams: sampleParams, + filteredEvents: sampleDocSearchResultsNoSortId(), + ruleSO, services: mockService, logger: mockLogger, id: sampleRuleGuid, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); @@ -278,29 +238,18 @@ describe('singleBulkCreate', () => { }); test('create failed bulk create when bulk create has multiple error statuses', async () => { - const sampleParams = sampleRuleAlertParams(); - const sampleSearchResult = sampleDocSearchResultsNoSortId; + const ruleSO = sampleRuleSO(getQueryRuleParams()); mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValue( elasticsearchClientMock.createSuccessTransportRequestPromise(sampleBulkCreateErrorResult) ); const { success, createdItemsCount, errors } = await singleBulkCreate({ - filteredEvents: sampleSearchResult(), - ruleParams: sampleParams, + filteredEvents: sampleDocSearchResultsNoSortId(), + ruleSO, services: mockService, logger: mockLogger, id: sampleRuleGuid, signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(mockLogger.error).toHaveBeenCalled(); @@ -349,28 +298,18 @@ describe('singleBulkCreate', () => { }); test('create successful and returns proper createdItemsCount', async () => { - const sampleParams = sampleRuleAlertParams(); + const ruleSO = sampleRuleSO(getQueryRuleParams()); mockService.scopedClusterClient.asCurrentUser.bulk.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(sampleBulkCreateDuplicateResult) ); const { success, createdItemsCount } = await singleBulkCreate({ filteredEvents: sampleDocSearchResultsNoSortId(), - ruleParams: sampleParams, + ruleSO, services: mockService, logger: mockLogger, id: sampleRuleGuid, signalsIndex: DEFAULT_SIGNALS_INDEX, - actions: [], - name: 'rule-name', - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', buildRuleMessage, }); expect(success).toEqual(true); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts index 8a0788f6d42e6d..92d01fef6e50c3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts @@ -12,32 +12,21 @@ import { AlertInstanceState, AlertServices, } from '../../../../../alerting/server'; -import { SignalHit, SignalSearchResponse, WrappedSignalHit } from './types'; -import { RuleAlertAction } from '../../../../common/detection_engine/types'; -import { RuleTypeParams, RefreshTypes } from '../types'; +import { AlertAttributes, SignalHit, SignalSearchResponse, WrappedSignalHit } from './types'; +import { RefreshTypes } from '../types'; import { generateId, makeFloatString, errorAggregator } from './utils'; import { buildBulkBody } from './build_bulk_body'; import { BuildRuleMessage } from './rule_messages'; -import { Logger } from '../../../../../../../src/core/server'; +import { Logger, SavedObject } from '../../../../../../../src/core/server'; import { isEventTypeSignal } from './build_event_type_signal'; interface SingleBulkCreateParams { filteredEvents: SignalSearchResponse; - ruleParams: RuleTypeParams; + ruleSO: SavedObject; services: AlertServices; logger: Logger; id: string; signalsIndex: string; - actions: RuleAlertAction[]; - name: string; - createdAt: string; - createdBy: string; - updatedAt: string; - updatedBy: string; - interval: string; - enabled: boolean; - tags: string[]; - throttle: string; refresh: RefreshTypes; buildRuleMessage: BuildRuleMessage; } @@ -97,23 +86,14 @@ export interface BulkInsertSignalsResponse { export const singleBulkCreate = async ({ buildRuleMessage, filteredEvents, - ruleParams, + ruleSO, services, logger, id, signalsIndex, - actions, - name, - createdAt, - createdBy, - updatedAt, - updatedBy, - interval, - enabled, refresh, - tags, - throttle, }: SingleBulkCreateParams): Promise => { + const ruleParams = ruleSO.attributes.params; filteredEvents.hits.hits = filterDuplicateRules(id, filteredEvents); logger.debug(buildRuleMessage(`about to bulk create ${filteredEvents.hits.hits.length} events`)); if (filteredEvents.hits.hits.length === 0) { @@ -141,21 +121,7 @@ export const singleBulkCreate = async ({ ), }, }, - buildBulkBody({ - doc, - ruleParams, - id, - actions, - name, - createdAt, - createdBy, - updatedAt, - updatedBy, - interval, - enabled, - tags, - throttle, - }), + buildBulkBody(ruleSO, doc), ]); const start = performance.now(); const { body: response } = await services.scopedClusterClient.asCurrentUser.bulk({ @@ -170,26 +136,11 @@ export const singleBulkCreate = async ({ ) ); logger.debug(buildRuleMessage(`took property says bulk took: ${response.took} milliseconds`)); - const createdItems = filteredEvents.hits.hits .map((doc, index) => ({ _id: response.items[index].create?._id ?? '', _index: response.items[index].create?._index ?? '', - ...buildBulkBody({ - doc, - ruleParams, - id, - actions, - name, - createdAt, - createdBy, - updatedAt, - updatedBy, - interval, - enabled, - tags, - throttle, - }), + ...buildBulkBody(ruleSO, doc), })) .filter((_, index) => get(response.items[index], 'create.status') === 201); const createdItemsCount = createdItems.length; 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 d9c72f7f95679e..37b0b88d88edab 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 @@ -29,20 +29,10 @@ export const createThreatSignal = async ({ eventsTelemetry, alertId, outputIndex, - params, + ruleSO, searchAfterSize, - actions, - createdBy, - createdAt, - updatedBy, - interval, - updatedAt, - enabled, refresh, - tags, - throttle, buildRuleMessage, - name, currentThreatList, currentResult, }: CreateThreatSignalOptions): Promise => { @@ -82,7 +72,7 @@ export const createThreatSignal = async ({ tuples, listClient, exceptionsList: exceptionItems, - ruleParams: params, + ruleSO, services, logger, eventsTelemetry, @@ -90,18 +80,8 @@ export const createThreatSignal = async ({ inputIndexPattern: inputIndex, signalsIndex: outputIndex, filter: esFilter, - actions, - name, - createdBy, - createdAt, - updatedBy, - updatedAt, - interval, - enabled, pageSize: searchAfterSize, refresh, - tags, - throttle, buildRuleMessage, enrichment: threatEnrichment, }); 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 8e42e60768bf02..ade85db0e4ba6c 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 @@ -30,28 +30,19 @@ export const createThreatSignals = async ({ eventsTelemetry, alertId, outputIndex, - params, + ruleSO, searchAfterSize, - actions, - createdBy, - createdAt, - updatedBy, - interval, - updatedAt, - enabled, refresh, - tags, - throttle, threatFilters, threatQuery, threatLanguage, buildRuleMessage, threatIndex, threatIndicatorPath, - name, concurrentSearches, itemsPerSearch, }: CreateThreatSignalsOptions): Promise => { + const params = ruleSO.attributes.params; logger.debug(buildRuleMessage('Indicator matching rule starting')); const perPage = concurrentSearches * itemsPerSearch; @@ -127,20 +118,10 @@ export const createThreatSignals = async ({ eventsTelemetry, alertId, outputIndex, - params, + ruleSO, searchAfterSize, - actions, - createdBy, - createdAt, - updatedBy, - updatedAt, - interval, - enabled, - tags, refresh, - throttle, buildRuleMessage, - name, currentThreatList: slicedChunk, currentResult: results, }) 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 aeed8da7ac3d98..360fb118faa84c 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 @@ -20,18 +20,22 @@ import { ItemsPerSearch, ThreatIndicatorPathOrUndefined, } from '../../../../../common/detection_engine/schemas/types/threat_mapping'; -import { RuleTypeParams } from '../../types'; import { AlertInstanceContext, AlertInstanceState, AlertServices, } from '../../../../../../alerting/server'; import { ExceptionListItemSchema } from '../../../../../../lists/common/schemas'; -import { ElasticsearchClient, Logger } from '../../../../../../../../src/core/server'; -import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ElasticsearchClient, Logger, SavedObject } from '../../../../../../../../src/core/server'; import { TelemetryEventsSender } from '../../../telemetry/sender'; import { BuildRuleMessage } from '../rule_messages'; -import { RuleRangeTuple, SearchAfterAndBulkCreateReturnType, SignalsEnrichment } from '../types'; +import { + AlertAttributes, + RuleRangeTuple, + SearchAfterAndBulkCreateReturnType, + SignalsEnrichment, +} from '../types'; +import { ThreatRuleParams } from '../../schemas/rule_schemas'; export type SortOrderOrUndefined = 'asc' | 'desc' | undefined; @@ -51,25 +55,15 @@ export interface CreateThreatSignalsOptions { eventsTelemetry: TelemetryEventsSender | undefined; alertId: string; outputIndex: string; - params: RuleTypeParams; + ruleSO: SavedObject>; searchAfterSize: number; - actions: RuleAlertAction[]; - createdBy: string; - createdAt: string; - updatedBy: string; - updatedAt: string; - interval: string; - enabled: boolean; - tags: string[]; refresh: false | 'wait_for'; - throttle: string; threatFilters: unknown[]; threatQuery: ThreatQuery; buildRuleMessage: BuildRuleMessage; threatIndex: ThreatIndex; threatIndicatorPath: ThreatIndicatorPathOrUndefined; threatLanguage: ThreatLanguageOrUndefined; - name: string; concurrentSearches: ConcurrentSearches; itemsPerSearch: ItemsPerSearch; } @@ -91,20 +85,10 @@ export interface CreateThreatSignalOptions { eventsTelemetry: TelemetryEventsSender | undefined; alertId: string; outputIndex: string; - params: RuleTypeParams; + ruleSO: SavedObject>; searchAfterSize: number; - actions: RuleAlertAction[]; - createdBy: string; - createdAt: string; - updatedBy: string; - updatedAt: string; - interval: string; - enabled: boolean; - tags: string[]; refresh: false | 'wait_for'; - throttle: string; buildRuleMessage: BuildRuleMessage; - name: string; currentThreatList: ThreatListItem[]; currentResult: SearchAfterAndBulkCreateReturnType; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.test.ts index c0fdc4eb0189d5..79c2d86f35e7bc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.test.ts @@ -6,175 +6,13 @@ */ import { loggingSystemMock } from '../../../../../../../../src/core/server/mocks'; -import { normalizeThresholdField } from '../../../../../common/detection_engine/utils'; -import { - Threshold, - ThresholdNormalized, -} from '../../../../../common/detection_engine/schemas/common/schemas'; +import { ThresholdNormalized } from '../../../../../common/detection_engine/schemas/common/schemas'; import { sampleDocNoSortId, sampleDocSearchResultsNoSortId } from '../__mocks__/es_results'; import { sampleThresholdSignalHistory } from '../__mocks__/threshold_signal_history.mock'; import { calculateThresholdSignalUuid } from '../utils'; import { transformThresholdResultsToEcs } from './bulk_create_threshold_signals'; describe('transformThresholdNormalizedResultsToEcs', () => { - it('should return transformed threshold results for pre-7.12 rules', () => { - const threshold: Threshold = { - field: 'source.ip', - value: 1, - }; - const from = new Date('2020-12-17T16:27:00Z'); - const startedAt = new Date('2020-12-17T16:27:00Z'); - const transformedResults = transformThresholdResultsToEcs( - { - ...sampleDocSearchResultsNoSortId('abcd'), - aggregations: { - 'threshold_0:source.ip': { - buckets: [ - { - key: '127.0.0.1', - doc_count: 15, - top_threshold_hits: { - hits: { - hits: [sampleDocNoSortId('abcd')], - }, - }, - }, - ], - }, - }, - }, - 'test', - startedAt, - from, - undefined, - loggingSystemMock.createLogger(), - { - ...threshold, - field: normalizeThresholdField(threshold.field), - }, - '1234', - undefined, - sampleThresholdSignalHistory() - ); - const _id = calculateThresholdSignalUuid('1234', startedAt, ['source.ip'], '127.0.0.1'); - expect(transformedResults).toEqual({ - took: 10, - timed_out: false, - _shards: { - total: 10, - successful: 10, - failed: 0, - skipped: 0, - }, - results: { - hits: { - total: 1, - }, - }, - hits: { - total: 100, - max_score: 100, - hits: [ - { - _id, - _index: 'test', - _source: { - 'source.ip': '127.0.0.1', - '@timestamp': '2020-04-20T21:27:45+0000', - threshold_result: { - from: new Date('2020-12-17T16:27:00.000Z'), - terms: [ - { - field: 'source.ip', - value: '127.0.0.1', - }, - ], - cardinality: undefined, - count: 15, - }, - }, - }, - ], - }, - }); - }); - - it('should return transformed threshold results for pre-7.12 rules without threshold field', () => { - const threshold: Threshold = { - field: '', - value: 1, - }; - const from = new Date('2020-12-17T16:27:00Z'); - const startedAt = new Date('2020-12-17T16:27:00Z'); - const transformedResults = transformThresholdResultsToEcs( - { - ...sampleDocSearchResultsNoSortId('abcd'), - aggregations: { - threshold_0: { - buckets: [ - { - key: '', - doc_count: 15, - top_threshold_hits: { - hits: { - hits: [sampleDocNoSortId('abcd')], - }, - }, - }, - ], - }, - }, - }, - 'test', - startedAt, - from, - undefined, - loggingSystemMock.createLogger(), - { - ...threshold, - field: normalizeThresholdField(threshold.field), - }, - '1234', - undefined, - sampleThresholdSignalHistory() - ); - const _id = calculateThresholdSignalUuid('1234', startedAt, [], ''); - expect(transformedResults).toEqual({ - took: 10, - timed_out: false, - _shards: { - total: 10, - successful: 10, - failed: 0, - skipped: 0, - }, - results: { - hits: { - total: 1, - }, - }, - hits: { - total: 100, - max_score: 100, - hits: [ - { - _id, - _index: 'test', - _source: { - '@timestamp': '2020-04-20T21:27:45+0000', - threshold_result: { - from: new Date('2020-12-17T16:27:00.000Z'), - terms: [], - cardinality: undefined, - count: 15, - }, - }, - }, - ], - }, - }); - }); - it('should return transformed threshold results', () => { const threshold: ThresholdNormalized = { field: ['source.ip', 'host.name'], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts index 8e5e31cc87b4f0..197065f205fc5a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts @@ -7,20 +7,19 @@ import { get } from 'lodash/fp'; import set from 'set-value'; -import { normalizeThresholdField } from '../../../../../common/detection_engine/utils'; import { ThresholdNormalized, TimestampOverrideOrUndefined, } from '../../../../../common/detection_engine/schemas/common/schemas'; -import { Logger } from '../../../../../../../../src/core/server'; +import { Logger, SavedObject } from '../../../../../../../../src/core/server'; import { AlertInstanceContext, AlertInstanceState, AlertServices, } from '../../../../../../alerting/server'; -import { BaseHit, RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { BaseHit } from '../../../../../common/detection_engine/types'; import { TermAggregationBucket } from '../../../types'; -import { RuleTypeParams, RefreshTypes } from '../../types'; +import { RefreshTypes } from '../../types'; import { singleBulkCreate, SingleBulkCreateResponse } from '../single_bulk_create'; import { calculateThresholdSignalUuid, @@ -33,29 +32,20 @@ import type { SignalSource, SignalSearchResponse, ThresholdSignalHistory, + AlertAttributes, } from '../types'; +import { ThresholdRuleParams } from '../../schemas/rule_schemas'; interface BulkCreateThresholdSignalsParams { - actions: RuleAlertAction[]; someResult: SignalSearchResponse; - ruleParams: RuleTypeParams; + ruleSO: SavedObject>; services: AlertServices; inputIndexPattern: string[]; logger: Logger; id: string; filter: unknown; signalsIndex: string; - timestampOverride: TimestampOverrideOrUndefined; - name: string; - createdAt: string; - createdBy: string; - updatedAt: string; - updatedBy: string; - interval: string; - enabled: boolean; refresh: RefreshTypes; - tags: string[]; - throttle: string; startedAt: Date; from: Date; thresholdSignalHistory: ThresholdSignalHistory; @@ -249,8 +239,8 @@ export const transformThresholdResultsToEcs = ( export const bulkCreateThresholdSignals = async ( params: BulkCreateThresholdSignalsParams ): Promise => { + const ruleParams = params.ruleSO.attributes.params; const thresholdResults = params.someResult; - const threshold = params.ruleParams.threshold!; const ecsResults = transformThresholdResultsToEcs( thresholdResults, params.inputIndexPattern.join(','), @@ -258,12 +248,9 @@ export const bulkCreateThresholdSignals = async ( params.from, params.filter, params.logger, - { - ...threshold, - field: normalizeThresholdField(threshold.field), - }, - params.ruleParams.ruleId, - params.timestampOverride, + ruleParams.threshold, + ruleParams.ruleId, + ruleParams.timestampOverride, params.thresholdSignalHistory ); const buildRuleMessage = params.buildRuleMessage; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.test.ts index 622e77309765f4..e84b4f31fb15f2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.test.ts @@ -31,108 +31,6 @@ describe('findThresholdSignals', () => { mockService = alertsMock.createAlertServices(); }); - it('should generate a threshold signal for pre-7.12 rules', async () => { - await findThresholdSignals({ - from: 'now-6m', - to: 'now', - inputIndexPattern: ['*'], - services: mockService, - logger: mockLogger, - filter: queryFilter, - threshold: { - field: 'host.name', - value: 100, - }, - buildRuleMessage, - timestampOverride: undefined, - }); - expect(mockSingleSearchAfter).toHaveBeenCalledWith( - expect.objectContaining({ - aggregations: { - 'threshold_0:host.name': { - terms: { - field: 'host.name', - min_doc_count: 100, - size: 10000, - }, - aggs: { - top_threshold_hits: { - top_hits: { - sort: [ - { - '@timestamp': { - order: 'desc', - }, - }, - ], - fields: [ - { - field: '*', - include_unmapped: true, - }, - ], - size: 1, - }, - }, - }, - }, - }, - }) - ); - }); - - it('should generate a signal for pre-7.12 rules with no threshold field', async () => { - await findThresholdSignals({ - from: 'now-6m', - to: 'now', - inputIndexPattern: ['*'], - services: mockService, - logger: mockLogger, - filter: queryFilter, - threshold: { - field: '', - value: 100, - }, - buildRuleMessage, - timestampOverride: undefined, - }); - expect(mockSingleSearchAfter).toHaveBeenCalledWith( - expect.objectContaining({ - aggregations: { - threshold_0: { - terms: { - script: { - source: '""', - lang: 'painless', - }, - min_doc_count: 100, - }, - aggs: { - top_threshold_hits: { - top_hits: { - sort: [ - { - '@timestamp': { - order: 'desc', - }, - }, - ], - fields: [ - { - field: '*', - include_unmapped: true, - }, - ], - size: 1, - }, - }, - }, - }, - }, - }) - ); - }); - it('should generate a threshold signal query when only a value is provided', async () => { await findThresholdSignals({ from: 'now-6m', @@ -246,6 +144,7 @@ describe('findThresholdSignals', () => { threshold: { field: ['host.name', 'user.name'], value: 100, + cardinality: [], }, buildRuleMessage, timestampOverride: undefined, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.ts index efcdb85e9b2c73..33ffa5b71a65c3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.ts @@ -8,10 +8,9 @@ import { set } from '@elastic/safer-lodash-set'; import { - Threshold, + ThresholdNormalized, TimestampOverrideOrUndefined, } from '../../../../../common/detection_engine/schemas/common/schemas'; -import { normalizeThresholdField } from '../../../../../common/detection_engine/utils'; import { AlertInstanceContext, AlertInstanceState, @@ -29,7 +28,7 @@ interface FindThresholdSignalsParams { services: AlertServices; logger: Logger; filter: unknown; - threshold: Threshold; + threshold: ThresholdNormalized; buildRuleMessage: BuildRuleMessage; timestampOverride: TimestampOverrideOrUndefined; } @@ -88,7 +87,7 @@ export const findThresholdSignals = async ({ : {}), }; - const thresholdFields = normalizeThresholdField(threshold.field); + const thresholdFields = threshold.field; // Generate a nested terms aggregation for each threshold grouping field provided, appending leaf // aggregations to 1) filter out buckets that don't meet the cardinality threshold, if provided, and diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts index 615b91d60bb1b8..80d08a77ba5d2f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts @@ -25,19 +25,13 @@ import { RuleAlertAction, SearchTypes, } from '../../../../common/detection_engine/types'; -import { RuleTypeParams, RefreshTypes } from '../types'; +import { RefreshTypes } from '../types'; import { ListClient } from '../../../../../lists/server'; -import { Logger } from '../../../../../../../src/core/server'; +import { Logger, SavedObject } from '../../../../../../../src/core/server'; import { ExceptionListItemSchema } from '../../../../../lists/common/schemas'; import { BuildRuleMessage } from './rule_messages'; import { TelemetryEventsSender } from '../../telemetry/sender'; -import { - EqlRuleParams, - MachineLearningRuleParams, - QueryRuleParams, - ThreatRuleParams, - ThresholdRuleParams, -} from '../schemas/rule_schemas'; +import { RuleParams } from '../schemas/rule_schemas'; // used for gap detection code // eslint-disable-next-line @typescript-eslint/naming-convention @@ -166,7 +160,7 @@ export type BaseSignalHit = estypes.Hit; export type EqlSignalSearchResponse = EqlSearchResponse; export type RuleExecutorOptions = AlertExecutorOptions< - RuleTypeParams, + RuleParams, AlertTypeState, AlertInstanceState, AlertInstanceContext @@ -177,7 +171,7 @@ export type RuleExecutorOptions = AlertExecutorOptions< export const isAlertExecutor = ( obj: SignalRuleAlertTypeDefinition ): obj is AlertType< - RuleTypeParams, + RuleParams, AlertTypeState, AlertInstanceState, AlertInstanceContext, @@ -187,7 +181,7 @@ export const isAlertExecutor = ( }; export type SignalRuleAlertTypeDefinition = AlertType< - RuleTypeParams, + RuleParams, AlertTypeState, AlertInstanceState, AlertInstanceContext, @@ -230,7 +224,7 @@ export interface SignalHit { [key: string]: SearchTypes; } -export interface AlertAttributes { +export interface AlertAttributes { actions: RuleAlertAction[]; enabled: boolean; name: string; @@ -242,30 +236,7 @@ export interface AlertAttributes { interval: string; }; throttle: string; -} - -export interface RuleAlertAttributes extends AlertAttributes { - params: RuleTypeParams; -} - -export interface MachineLearningRuleAttributes extends AlertAttributes { - params: MachineLearningRuleParams; -} - -export interface ThresholdRuleAttributes extends AlertAttributes { - params: ThresholdRuleParams; -} - -export interface ThreatRuleAttributes extends AlertAttributes { - params: ThreatRuleParams; -} - -export interface QueryRuleAttributes extends AlertAttributes { - params: QueryRuleParams; -} - -export interface EqlRuleAttributes extends AlertAttributes { - params: EqlRuleParams; + params: T; } export type BulkResponseErrorAggregation = Record; @@ -290,7 +261,7 @@ export interface SearchAfterAndBulkCreateParams { from: moment.Moment; maxSignals: number; }>; - ruleParams: RuleTypeParams; + ruleSO: SavedObject; services: AlertServices; listClient: ListClient; exceptionsList: ExceptionListItemSchema[]; @@ -299,19 +270,9 @@ export interface SearchAfterAndBulkCreateParams { id: string; inputIndexPattern: string[]; signalsIndex: string; - name: string; - actions: RuleAlertAction[]; - createdAt: string; - createdBy: string; - updatedBy: string; - updatedAt: string; - interval: string; - enabled: boolean; pageSize: number; filter: unknown; refresh: RefreshTypes; - tags: string[]; - throttle: string; buildRuleMessage: BuildRuleMessage; enrichment?: SignalsEnrichment; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts index fb0166fd4dbee9..54ed44956c8b36 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts @@ -42,6 +42,15 @@ import { hasLargeValueList } from '../../../../common/detection_engine/utils'; import { MAX_EXCEPTION_LIST_SIZE } from '../../../../../lists/common/constants'; import { ShardError } from '../../types'; import { RuleStatusService } from './rule_status_service'; +import { + EqlRuleParams, + MachineLearningRuleParams, + QueryRuleParams, + RuleParams, + SavedQueryRuleParams, + ThreatRuleParams, + ThresholdRuleParams, +} from '../schemas/rule_schemas'; interface SortExceptionsReturn { exceptionsWithValueLists: ExceptionListItemSchema[]; @@ -825,3 +834,15 @@ export const getThresholdTermsHash = ( ) .digest('hex'); }; + +export const isEqlParams = (params: RuleParams): params is EqlRuleParams => params.type === 'eql'; +export const isThresholdParams = (params: RuleParams): params is ThresholdRuleParams => + params.type === 'threshold'; +export const isQueryParams = (params: RuleParams): params is QueryRuleParams => + params.type === 'query'; +export const isSavedQueryParams = (params: RuleParams): params is SavedQueryRuleParams => + params.type === 'saved_query'; +export const isThreatParams = (params: RuleParams): params is ThreatRuleParams => + params.type === 'threat_match'; +export const isMachineLearningParams = (params: RuleParams): params is MachineLearningRuleParams => + params.type === 'machine_learning'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/tags/read_tags.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/tags/read_tags.test.ts index 918857b976bea5..b2a589dacd371c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/tags/read_tags.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/tags/read_tags.test.ts @@ -6,9 +6,10 @@ */ import { alertsClientMock } from '../../../../../alerting/server/mocks'; -import { getResult, getFindResultWithMultiHits } from '../routes/__mocks__/request_responses'; +import { getAlertMock, getFindResultWithMultiHits } from '../routes/__mocks__/request_responses'; import { INTERNAL_RULE_ID_KEY, INTERNAL_IDENTIFIER } from '../../../../common/constants'; import { readRawTags, readTags, convertTagsToSet, convertToTags, isTags } from './read_tags'; +import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; describe('read_tags', () => { afterEach(() => { @@ -17,12 +18,12 @@ describe('read_tags', () => { describe('readRawTags', () => { test('it should return the intersection of tags to where none are repeating', async () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = ['tag 1', 'tag 2', 'tag 3']; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result2.params.ruleId = 'rule-2'; result2.tags = ['tag 1', 'tag 2', 'tag 3', 'tag 4']; @@ -35,12 +36,12 @@ describe('read_tags', () => { }); test('it should return the intersection of tags to where some are repeating values', async () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = ['tag 1', 'tag 2', 'tag 2', 'tag 3']; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result2.params.ruleId = 'rule-2'; result2.tags = ['tag 1', 'tag 2', 'tag 2', 'tag 3', 'tag 4']; @@ -53,12 +54,12 @@ describe('read_tags', () => { }); test('it should work with no tags defined between two results', async () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = []; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result2.params.ruleId = 'rule-2'; result2.tags = []; @@ -71,7 +72,7 @@ describe('read_tags', () => { }); test('it should work with a single tag which has repeating values in it', async () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = ['tag 1', 'tag 1', 'tag 1', 'tag 2']; @@ -84,7 +85,7 @@ describe('read_tags', () => { }); test('it should work with a single tag which has empty tags', async () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = []; @@ -99,12 +100,12 @@ describe('read_tags', () => { describe('readTags', () => { test('it should return the intersection of tags to where none are repeating', async () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = ['tag 1', 'tag 2', 'tag 3']; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result2.params.ruleId = 'rule-2'; result2.tags = ['tag 1', 'tag 2', 'tag 3', 'tag 4']; @@ -117,12 +118,12 @@ describe('read_tags', () => { }); test('it should return the intersection of tags to where some are repeating values', async () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = ['tag 1', 'tag 2', 'tag 2', 'tag 3']; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result2.params.ruleId = 'rule-2'; result2.tags = ['tag 1', 'tag 2', 'tag 2', 'tag 3', 'tag 4']; @@ -135,12 +136,12 @@ describe('read_tags', () => { }); test('it should work with no tags defined between two results', async () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = []; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result2.params.ruleId = 'rule-2'; result2.tags = []; @@ -153,7 +154,7 @@ describe('read_tags', () => { }); test('it should work with a single tag which has repeating values in it', async () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = ['tag 1', 'tag 1', 'tag 1', 'tag 2']; @@ -166,7 +167,7 @@ describe('read_tags', () => { }); test('it should work with a single tag which has empty tags', async () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = []; @@ -179,7 +180,7 @@ describe('read_tags', () => { }); test('it should filter out any __internal tags for things such as alert_id', async () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = [ @@ -196,7 +197,7 @@ describe('read_tags', () => { }); test('it should filter out any __internal tags with two different results', async () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = [ @@ -209,7 +210,7 @@ describe('read_tags', () => { 'tag 5', ]; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result2.params.ruleId = 'rule-2'; result2.tags = [ @@ -231,12 +232,12 @@ describe('read_tags', () => { describe('convertTagsToSet', () => { test('it should convert the intersection of two tag systems without duplicates', () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = ['tag 1', 'tag 2', 'tag 2', 'tag 3']; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result2.params.ruleId = 'rule-2'; result2.tags = ['tag 1', 'tag 2', 'tag 2', 'tag 3', 'tag 4']; @@ -254,12 +255,12 @@ describe('read_tags', () => { describe('convertToTags', () => { test('it should convert the two tag systems together with duplicates', () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = ['tag 1', 'tag 2', 'tag 2', 'tag 3']; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result2.params.ruleId = 'rule-2'; result2.tags = ['tag 1', 'tag 2', 'tag 2', 'tag 3', 'tag 4']; @@ -280,18 +281,18 @@ describe('read_tags', () => { }); test('it should filter out anything that is not a tag', () => { - const result1 = getResult(); + const result1 = getAlertMock(getQueryRuleParams()); result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result1.params.ruleId = 'rule-1'; result1.tags = ['tag 1', 'tag 2', 'tag 2', 'tag 3']; - const result2 = getResult(); + const result2 = getAlertMock(getQueryRuleParams()); result2.id = '99979e67-19a7-455f-b452-8eded6135716'; result2.params.ruleId = 'rule-2'; // @ts-expect-error delete result2.tags; - const result3 = getResult(); + const result3 = getAlertMock(getQueryRuleParams()); result3.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; result3.params.ruleId = 'rule-2'; result3.tags = ['tag 1', 'tag 2', 'tag 2', 'tag 3', 'tag 4']; From e35ecaa3785dd9521a1c144ee50b56084cc4c4f5 Mon Sep 17 00:00:00 2001 From: Ross Wolf <31489089+rw-access@users.noreply.github.com> Date: Wed, 14 Apr 2021 10:57:50 -0600 Subject: [PATCH 86/90] [Security] Adds pre-packaged rule updates through the "Prebuilt Security Detection Rules" Fleet integration (#96698) * Make the prepackaged rules functions async * Fix type for getPrepackagedRules mock * Install updates from saved objects & FS * Mock getLatestPrepackagedRules instead of getPrepackagedRules * Cleanup ruleAssetSavedObjectsClientFactory.all * Fix comment for "most recent version" * Switch to ruleMap.get() for less typescript errors * Remove unneeded constants * Fix SO.attributes sig and use custom validation --- .../rules/add_prepackaged_rules_route.test.ts | 2 +- .../rules/add_prepackaged_rules_route.ts | 11 +-- ...get_prepackaged_rules_status_route.test.ts | 2 +- .../get_prepackaged_rules_status_route.ts | 11 +-- .../rules/get_prepackaged_rules.test.ts | 6 +- .../rules/get_prepackaged_rules.ts | 68 ++++++++++++++++++- .../rules/rule_asset_saved_objects_client.ts | 47 +++++++++++++ .../lib/detection_engine/rules/types.ts | 13 ++++ 8 files changed, 146 insertions(+), 14 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/rule_asset_saved_objects_client.ts diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts index 1195f9e5e1e967..026820a8f2ff76 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts @@ -25,7 +25,7 @@ import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mo jest.mock('../../rules/get_prepackaged_rules', () => { return { - getPrepackagedRules: (): AddPrepackagedRulesSchemaDecoded[] => { + getLatestPrepackagedRules: async (): Promise => { return [ { author: ['Elastic'], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts index 8a8d6925b0e800..4f9bd7d0cfd6c5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts @@ -25,12 +25,13 @@ import { SetupPlugins } from '../../../../plugin'; import { buildFrameworkRequest } from '../../../timeline/utils/common'; import { getIndexExists } from '../../index/get_index_exists'; -import { getPrepackagedRules } from '../../rules/get_prepackaged_rules'; +import { getLatestPrepackagedRules } from '../../rules/get_prepackaged_rules'; import { installPrepackagedRules } from '../../rules/install_prepacked_rules'; import { updatePrepackagedRules } from '../../rules/update_prepacked_rules'; import { getRulesToInstall } from '../../rules/get_rules_to_install'; import { getRulesToUpdate } from '../../rules/get_rules_to_update'; import { getExistingPrepackagedRules } from '../../rules/get_existing_prepackaged_rules'; +import { ruleAssetSavedObjectsClientFactory } from '../../rules/rule_asset_saved_objects_client'; import { transformError, buildSiemResponse } from '../utils'; import { AlertsClient } from '../../../../../../alerting/server'; @@ -110,7 +111,7 @@ export const createPrepackagedRules = async ( const savedObjectsClient = context.core.savedObjects.client; const exceptionsListClient = context.lists != null ? context.lists.getExceptionListClient() : exceptionsClient; - + const ruleAssetsClient = ruleAssetSavedObjectsClientFactory(savedObjectsClient); if (!siemClient || !alertsClient) { throw new PrepackagedRulesError('', 404); } @@ -120,10 +121,10 @@ export const createPrepackagedRules = async ( await exceptionsListClient.createEndpointList(); } - const rulesFromFileSystem = getPrepackagedRules(); + const latestPrepackagedRules = await getLatestPrepackagedRules(ruleAssetsClient); const prepackagedRules = await getExistingPrepackagedRules({ alertsClient }); - const rulesToInstall = getRulesToInstall(rulesFromFileSystem, prepackagedRules); - const rulesToUpdate = getRulesToUpdate(rulesFromFileSystem, prepackagedRules); + const rulesToInstall = getRulesToInstall(latestPrepackagedRules, prepackagedRules); + const rulesToUpdate = getRulesToUpdate(latestPrepackagedRules, prepackagedRules); const signalsIndex = siemClient.getSignalsIndex(); if (rulesToInstall.length !== 0 || rulesToUpdate.length !== 0) { const signalsIndexExists = await getIndexExists(esClient.asCurrentUser, signalsIndex); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.test.ts index 9e843d463ab3e2..3c8321ee8eb9a5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.test.ts @@ -23,7 +23,7 @@ import { jest.mock('../../rules/get_prepackaged_rules', () => { return { - getPrepackagedRules: () => { + getLatestPrepackagedRules: async () => { return [ { rule_id: 'rule-1', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.ts index c67f2cb6e9545f..33f9746fe9245c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.ts @@ -13,11 +13,12 @@ import { import type { SecuritySolutionPluginRouter } from '../../../../types'; import { DETECTION_ENGINE_PREPACKAGED_URL } from '../../../../../common/constants'; import { transformError, buildSiemResponse } from '../utils'; -import { getPrepackagedRules } from '../../rules/get_prepackaged_rules'; import { getRulesToInstall } from '../../rules/get_rules_to_install'; import { getRulesToUpdate } from '../../rules/get_rules_to_update'; import { findRules } from '../../rules/find_rules'; +import { getLatestPrepackagedRules } from '../../rules/get_prepackaged_rules'; import { getExistingPrepackagedRules } from '../../rules/get_existing_prepackaged_rules'; +import { ruleAssetSavedObjectsClientFactory } from '../../rules/rule_asset_saved_objects_client'; import { buildFrameworkRequest } from '../../../timeline/utils/common'; import { ConfigType } from '../../../../config'; import { SetupPlugins } from '../../../../plugin'; @@ -40,15 +41,17 @@ export const getPrepackagedRulesStatusRoute = ( }, }, async (context, request, response) => { + const savedObjectsClient = context.core.savedObjects.client; const siemResponse = buildSiemResponse(response); const alertsClient = context.alerting?.getAlertsClient(); + const ruleAssetsClient = ruleAssetSavedObjectsClientFactory(savedObjectsClient); if (!alertsClient) { return siemResponse.error({ statusCode: 404 }); } try { - const rulesFromFileSystem = getPrepackagedRules(); + const latestPrepackagedRules = await getLatestPrepackagedRules(ruleAssetsClient); const customRules = await findRules({ alertsClient, perPage: 1, @@ -61,8 +64,8 @@ export const getPrepackagedRulesStatusRoute = ( const frameworkRequest = await buildFrameworkRequest(context, security, request); const prepackagedRules = await getExistingPrepackagedRules({ alertsClient }); - const rulesToInstall = getRulesToInstall(rulesFromFileSystem, prepackagedRules); - const rulesToUpdate = getRulesToUpdate(rulesFromFileSystem, prepackagedRules); + const rulesToInstall = getRulesToInstall(latestPrepackagedRules, prepackagedRules); + const rulesToUpdate = getRulesToUpdate(latestPrepackagedRules, prepackagedRules); const prepackagedTimelineStatus = await checkTimelinesStatus(frameworkRequest); const [validatedprepackagedTimelineStatus] = validate( prepackagedTimelineStatus, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.test.ts index 039bc8c1e2e497..2d92731dbbdfdf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.test.ts @@ -41,8 +41,10 @@ describe('get_existing_prepackaged_rules', () => { }); test('should throw an exception with a message having rule_id and name in it', () => { - // @ts-expect-error intentionally invalid argument - expect(() => getPrepackagedRules([{ name: 'rule name', rule_id: 'id-123' }])).toThrow( + expect(() => + // @ts-expect-error intentionally invalid argument + getPrepackagedRules([{ name: 'rule name', rule_id: 'id-123' }]) + ).toThrow( 'name: "rule name", rule_id: "id-123" within the folder rules/prepackaged_rules is not a valid detection engine rule. Expect the system to not work with pre-packaged rules until this rule is fixed or the file is removed. Error is: Invalid value "undefined" supplied to "description",Invalid value "undefined" supplied to "risk_score",Invalid value "undefined" supplied to "severity",Invalid value "undefined" supplied to "type",Invalid value "undefined" supplied to "version", Full rule contents are:\n{\n "name": "rule name",\n "rule_id": "id-123"\n}' ); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.ts index 508238afcb6df9..b91557c6d7b1bb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.ts @@ -19,6 +19,9 @@ import { BadRequestError } from '../errors/bad_request_error'; // TODO: convert rules files to TS and add explicit type definitions import { rawRules } from './prepackaged_rules'; +import { RuleAssetSavedObjectsClient } from './rule_asset_saved_objects_client'; +import { IRuleAssetSOAttributes } from './types'; +import { SavedObjectAttributes } from '../../../../../../../src/core/types'; /** * Validate the rules from the file system and throw any errors indicating to the developer @@ -52,7 +55,70 @@ export const validateAllPrepackagedRules = ( }); }; +/** + * Validate the rules from Saved Objects created by Fleet. + */ +export const validateAllRuleSavedObjects = ( + rules: Array +): AddPrepackagedRulesSchemaDecoded[] => { + return rules.map((rule) => { + const decoded = addPrepackagedRulesSchema.decode(rule); + const checked = exactCheck(rule, decoded); + + const onLeft = (errors: t.Errors): AddPrepackagedRulesSchemaDecoded => { + const ruleName = rule.name ? rule.name : '(rule name unknown)'; + const ruleId = rule.rule_id ? rule.rule_id : '(rule rule_id unknown)'; + throw new BadRequestError( + `name: "${ruleName}", rule_id: "${ruleId}" within the security-rule saved object ` + + `is not a valid detection engine rule. Expect the system ` + + `to not work with pre-packaged rules until this rule is fixed ` + + `or the file is removed. Error is: ${formatErrors( + errors + ).join()}, Full rule contents are:\n${JSON.stringify(rule, null, 2)}` + ); + }; + + const onRight = (schema: AddPrepackagedRulesSchema): AddPrepackagedRulesSchemaDecoded => { + return schema as AddPrepackagedRulesSchemaDecoded; + }; + return pipe(checked, fold(onLeft, onRight)); + }); +}; + +/** + * Retrieve and validate rules that were installed from Fleet as saved objects. + */ +export const getFleetInstalledRules = async ( + client: RuleAssetSavedObjectsClient +): Promise => { + const fleetResponse = await client.all(); + const fleetRules = fleetResponse.map((so) => so.attributes); + return validateAllRuleSavedObjects(fleetRules); +}; + export const getPrepackagedRules = ( // @ts-expect-error mock data is too loosely typed rules: AddPrepackagedRulesSchema[] = rawRules -): AddPrepackagedRulesSchemaDecoded[] => validateAllPrepackagedRules(rules); +): AddPrepackagedRulesSchemaDecoded[] => { + return validateAllPrepackagedRules(rules); +}; + +export const getLatestPrepackagedRules = async ( + client: RuleAssetSavedObjectsClient +): Promise => { + // build a map of the most recent version of each rule + const prepackaged = getPrepackagedRules(); + const ruleMap = new Map(prepackaged.map((r) => [r.rule_id, r])); + + // check the rules installed via fleet and create/update if the version is newer + const fleetRules = await getFleetInstalledRules(client); + const fleetUpdates = fleetRules.filter((r) => { + const rule = ruleMap.get(r.rule_id); + return rule == null || rule.version < r.version; + }); + + // add the new or updated rules to the map + fleetUpdates.forEach((r) => ruleMap.set(r.rule_id, r)); + + return Array.from(ruleMap.values()); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/rule_asset_saved_objects_client.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/rule_asset_saved_objects_client.ts new file mode 100644 index 00000000000000..ac0969dfc975d0 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/rule_asset_saved_objects_client.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + SavedObjectsClientContract, + SavedObjectsFindOptions, + SavedObjectsFindResponse, +} from '../../../../../../../src/core/server'; +import { ruleAssetSavedObjectType } from '../rules/saved_object_mappings'; +import { IRuleAssetSavedObject } from '../rules/types'; + +const DEFAULT_PAGE_SIZE = 100; + +export interface RuleAssetSavedObjectsClient { + find: ( + options?: Omit + ) => Promise>; + all: () => Promise; +} + +export const ruleAssetSavedObjectsClientFactory = ( + savedObjectsClient: SavedObjectsClientContract +): RuleAssetSavedObjectsClient => { + return { + find: (options) => + savedObjectsClient.find({ + ...options, + type: ruleAssetSavedObjectType, + }), + all: async () => { + const finder = savedObjectsClient.createPointInTimeFinder({ + perPage: DEFAULT_PAGE_SIZE, + type: ruleAssetSavedObjectType, + }); + const responses: IRuleAssetSavedObject[] = []; + for await (const response of finder.find()) { + responses.push(...response.saved_objects.map((so) => so as IRuleAssetSavedObject)); + } + await finder.close(); + return responses; + }, + }; +}; 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 2a87b008293216..2990a0f7280278 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 @@ -164,6 +164,19 @@ export interface IRuleStatusFindType { saved_objects: IRuleStatusSavedObject[]; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface IRuleAssetSOAttributes extends Record { + rule_id: string | null | undefined; + version: string | null | undefined; + name: string | null | undefined; +} + +export interface IRuleAssetSavedObject { + type: string; + id: string; + attributes: IRuleAssetSOAttributes & SavedObjectAttributes; +} + export interface HapiReadableStream extends Readable { hapi: { filename: string; From 096536647f69875d835d5cc055ec3b27cbec09bf Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Wed, 14 Apr 2021 19:11:44 +0200 Subject: [PATCH 87/90] [ML] fix vertical overflow (#97127) --- .../ml/public/application/explorer/swimlane_container.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx index 4adb79f065cd4a..c108257094b6aa 100644 --- a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx @@ -361,7 +361,7 @@ export const SwimlaneContainer: FC = ({ From 3bc2952216f905620afe019af4b3785e385f000d Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Wed, 14 Apr 2021 12:28:00 -0500 Subject: [PATCH 88/90] [Workplace Search] Bypass UnsavedChangesPrompt for tab changes in Display Settings (#97062) * Move redirect logic into logic file * Add logic to prevent prompt from triggering when changing tabs The idea here is to set a boolean flag that sends false for unsavedChanges when switching between tabs and then sets it back after a successful tab change * Keep sidebar nav item active for both tabs * Add tests --- .../display_settings.test.tsx | 8 ++-- .../display_settings/display_settings.tsx | 28 ++++--------- .../display_settings_logic.test.ts | 40 ++++++++++++++++++- .../display_settings_logic.ts | 39 ++++++++++++++++++ .../components/source_sub_nav.tsx | 5 ++- 5 files changed, 94 insertions(+), 26 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.test.tsx index c1f526e24b8e22..54be43596a4314 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.test.tsx @@ -7,7 +7,6 @@ import '../../../../../__mocks__/shallow_useeffect.mock'; -import { mockKibanaValues } from '../../../../../__mocks__'; import { setMockValues, setMockActions } from '../../../../../__mocks__'; import { exampleResult } from '../../../../__mocks__/content_sources.mock'; @@ -25,11 +24,11 @@ import { DisplaySettings } from './display_settings'; import { FieldEditorModal } from './field_editor_modal'; describe('DisplaySettings', () => { - const { navigateToUrl } = mockKibanaValues; const { exampleDocuments, searchResultConfig } = exampleResult; const initializeDisplaySettings = jest.fn(); const setServerData = jest.fn(); const setColorField = jest.fn(); + const handleSelectedTabChanged = jest.fn(); const values = { isOrganization: true, @@ -46,6 +45,7 @@ describe('DisplaySettings', () => { initializeDisplaySettings, setServerData, setColorField, + handleSelectedTabChanged, }); setMockValues({ ...values }); }); @@ -83,7 +83,7 @@ describe('DisplaySettings', () => { const tabsEl = wrapper.find(EuiTabbedContent); tabsEl.prop('onTabClick')!(tabs[0]); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/123/display_settings/'); + expect(handleSelectedTabChanged).toHaveBeenCalledWith('search_results'); }); it('handles second tab click', () => { @@ -91,7 +91,7 @@ describe('DisplaySettings', () => { const tabsEl = wrapper.find(EuiTabbedContent); tabsEl.prop('onTabClick')!(tabs[1]); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/123/display_settings/result_detail'); + expect(handleSelectedTabChanged).toHaveBeenCalledWith('result_detail'); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx index e39a8d17e406c9..3441e5fcbaf82c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx @@ -20,19 +20,11 @@ import { } from '@elastic/eui'; import { clearFlashMessages } from '../../../../../shared/flash_messages'; -import { KibanaLogic } from '../../../../../shared/kibana'; import { Loading } from '../../../../../shared/loading'; import { UnsavedChangesPrompt } from '../../../../../shared/unsaved_changes_prompt'; -import { AppLogic } from '../../../../app_logic'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; import { SAVE_BUTTON } from '../../../../constants'; -import { - DISPLAY_SETTINGS_RESULT_DETAIL_PATH, - DISPLAY_SETTINGS_SEARCH_RESULT_PATH, - getContentSourcePath, -} from '../../../../routes'; - import { UNSAVED_MESSAGE, DISPLAY_SETTINGS_TITLE, @@ -42,7 +34,7 @@ import { SEARCH_RESULTS_LABEL, RESULT_DETAIL_LABEL, } from './constants'; -import { DisplaySettingsLogic } from './display_settings_logic'; +import { DisplaySettingsLogic, TabId } from './display_settings_logic'; import { FieldEditorModal } from './field_editor_modal'; import { ResultDetail } from './result_detail'; import { SearchResults } from './search_results'; @@ -52,19 +44,20 @@ interface DisplaySettingsProps { } export const DisplaySettings: React.FC = ({ tabId }) => { - const { initializeDisplaySettings, setServerData } = useActions(DisplaySettingsLogic); + const { initializeDisplaySettings, setServerData, handleSelectedTabChanged } = useActions( + DisplaySettingsLogic + ); const { dataLoading, - sourceId, addFieldModalVisible, unsavedChanges, exampleDocuments, + navigatingBetweenTabs, } = useValues(DisplaySettingsLogic); - const { isOrganization } = useValues(AppLogic); - const hasDocuments = exampleDocuments.length > 0; + const hasUnsavedChanges = hasDocuments && unsavedChanges; useEffect(() => { initializeDisplaySettings(); @@ -87,12 +80,7 @@ export const DisplaySettings: React.FC = ({ tabId }) => { ] as EuiTabbedContentTab[]; const onSelectedTabChanged = (tab: EuiTabbedContentTab) => { - const path = - tab.id === tabs[1].id - ? getContentSourcePath(DISPLAY_SETTINGS_RESULT_DETAIL_PATH, sourceId, isOrganization) - : getContentSourcePath(DISPLAY_SETTINGS_SEARCH_RESULT_PATH, sourceId, isOrganization); - - KibanaLogic.values.navigateToUrl(path); + handleSelectedTabChanged(tab.id as TabId); }; const handleFormSubmit = (e: FormEvent) => { @@ -103,7 +91,7 @@ export const DisplaySettings: React.FC = ({ tabId }) => { return ( <> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.test.ts index 73df0298ecd196..5a6ef5ba5990f1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.test.ts @@ -5,7 +5,12 @@ * 2.0. */ -import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../../../__mocks__'; +import { + LogicMounter, + mockFlashMessageHelpers, + mockHttpValues, + mockKibanaValues, +} from '../../../../../__mocks__'; import { exampleResult } from '../../../../__mocks__/content_sources.mock'; import { nextTick } from '@kbn/test/jest'; @@ -25,6 +30,7 @@ import { DisplaySettingsLogic, defaultSearchResultConfig } from './display_setti describe('DisplaySettingsLogic', () => { const { http } = mockHttpValues; + const { navigateToUrl } = mockKibanaValues; const { clearFlashMessages, flashAPIErrors, setSuccessMessage } = mockFlashMessageHelpers; const { mount } = new LogicMounter(DisplaySettingsLogic); @@ -40,6 +46,7 @@ describe('DisplaySettingsLogic', () => { serverRoute: '', editFieldIndex: null, dataLoading: true, + navigatingBetweenTabs: false, addFieldModalVisible: false, titleFieldHover: false, urlFieldHover: false, @@ -203,6 +210,12 @@ describe('DisplaySettingsLogic', () => { }); }); + it('setNavigatingBetweenTabs', () => { + DisplaySettingsLogic.actions.setNavigatingBetweenTabs(true); + + expect(DisplaySettingsLogic.values.navigatingBetweenTabs).toEqual(true); + }); + it('addDetailField', () => { const newField = { label: 'Monkey', fieldName: 'primate' }; DisplaySettingsLogic.actions.setServerResponseData(serverProps); @@ -351,6 +364,31 @@ describe('DisplaySettingsLogic', () => { expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); }); + + describe('handleSelectedTabChanged', () => { + beforeEach(() => { + DisplaySettingsLogic.actions.onInitializeDisplaySettings(serverProps); + }); + + it('calls sets navigatingBetweenTabs', async () => { + const setNavigatingBetweenTabsSpy = jest.spyOn( + DisplaySettingsLogic.actions, + 'setNavigatingBetweenTabs' + ); + DisplaySettingsLogic.actions.handleSelectedTabChanged('search_results'); + await nextTick(); + + expect(setNavigatingBetweenTabsSpy).toHaveBeenCalledWith(true); + expect(navigateToUrl).toHaveBeenCalledWith('/p/sources/123/display_settings/'); + }); + + it('calls calls correct route for "result_detail"', async () => { + DisplaySettingsLogic.actions.handleSelectedTabChanged('result_detail'); + await nextTick(); + + expect(navigateToUrl).toHaveBeenCalledWith('/p/sources/123/display_settings/result_detail'); + }); + }); }); describe('selectors', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.ts index 62d959083af594..e8b419a31abb2d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.ts @@ -16,7 +16,13 @@ import { flashAPIErrors, } from '../../../../../shared/flash_messages'; import { HttpLogic } from '../../../../../shared/http'; +import { KibanaLogic } from '../../../../../shared/kibana'; import { AppLogic } from '../../../../app_logic'; +import { + DISPLAY_SETTINGS_RESULT_DETAIL_PATH, + DISPLAY_SETTINGS_SEARCH_RESULT_PATH, + getContentSourcePath, +} from '../../../../routes'; import { DetailField, SearchResultConfig, OptionValue, Result } from '../../../../types'; import { SourceLogic } from '../../source_logic'; @@ -34,6 +40,8 @@ export interface DisplaySettingsInitialData extends DisplaySettingsResponseProps serverRoute: string; } +export type TabId = 'search_results' | 'result_detail'; + interface DisplaySettingsActions { initializeDisplaySettings(): void; setServerData(): void; @@ -51,6 +59,8 @@ interface DisplaySettingsActions { setDetailFields(result: DropResult): { result: DropResult }; openEditDetailField(editFieldIndex: number | null): number | null; removeDetailField(index: number): number; + setNavigatingBetweenTabs(navigatingBetweenTabs: boolean): boolean; + handleSelectedTabChanged(tabId: TabId): TabId; addDetailField(newField: DetailField): DetailField; updateDetailField( updatedField: DetailField, @@ -73,6 +83,7 @@ interface DisplaySettingsValues { serverRoute: string; editFieldIndex: number | null; dataLoading: boolean; + navigatingBetweenTabs: boolean; addFieldModalVisible: boolean; titleFieldHover: boolean; urlFieldHover: boolean; @@ -109,6 +120,8 @@ export const DisplaySettingsLogic = kea< setDetailFields: (result: DropResult) => ({ result }), openEditDetailField: (editFieldIndex: number | null) => editFieldIndex, removeDetailField: (index: number) => index, + setNavigatingBetweenTabs: (navigatingBetweenTabs: boolean) => navigatingBetweenTabs, + handleSelectedTabChanged: (tabId: TabId) => tabId, addDetailField: (newField: DetailField) => newField, updateDetailField: (updatedField: DetailField, index: number) => ({ updatedField, index }), toggleFieldEditorModal: () => true, @@ -224,6 +237,12 @@ export const DisplaySettingsLogic = kea< onInitializeDisplaySettings: () => false, }, ], + navigatingBetweenTabs: [ + false, + { + setNavigatingBetweenTabs: (_, navigatingBetweenTabs) => navigatingBetweenTabs, + }, + ], addFieldModalVisible: [ false, { @@ -330,6 +349,26 @@ export const DisplaySettingsLogic = kea< toggleFieldEditorModal: () => { clearFlashMessages(); }, + + handleSelectedTabChanged: async (tabId, breakpoint) => { + const { isOrganization } = AppLogic.values; + const { sourceId } = values; + const path = + tabId === 'result_detail' + ? getContentSourcePath(DISPLAY_SETTINGS_RESULT_DETAIL_PATH, sourceId, isOrganization) + : getContentSourcePath(DISPLAY_SETTINGS_SEARCH_RESULT_PATH, sourceId, isOrganization); + + // This method is needed because the shared `UnsavedChangesPrompt` component is triggered + // when navigating between tabs. We set a boolean flag that tells the prompt there are no + // unsaved changes when navigating between the tabs and reset it one the transition is complete + // in order to restore the intended functionality when navigating away with unsaved changes. + actions.setNavigatingBetweenTabs(true); + + await breakpoint(); + + KibanaLogic.values.navigateToUrl(path); + actions.setNavigatingBetweenTabs(false); + }, }), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx index bf0c5471f7b574..12e1506ec6efda 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx @@ -45,7 +45,10 @@ export const SourceSubNav: React.FC = () => { {NAV.SCHEMA} - + {NAV.DISPLAY_SETTINGS} From 0bfa5aaf013b834ecbd21d5338529b8d193f1a12 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Wed, 14 Apr 2021 19:49:19 +0100 Subject: [PATCH 89/90] chore(NA): moving @kbn/tinymath into bazel (#97022) * chore(NA): moving @kbn/tinymath into bazel * chore(NA): fixed jest tests * chore(NA): simplified tsconfig file Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../monorepo-packages.asciidoc | 1 + package.json | 2 +- packages/BUILD.bazel | 3 +- packages/kbn-tinymath/BUILD.bazel | 71 + .../{src => grammar}/grammar.pegjs | 2 +- .../{tinymath.d.ts => index.d.ts} | 0 packages/kbn-tinymath/package.json | 7 +- packages/kbn-tinymath/src/grammar.js | 1555 ----------------- packages/kbn-tinymath/src/index.js | 3 +- packages/kbn-tinymath/test/library.test.js | 2 +- packages/kbn-tinymath/tsconfig.json | 5 +- yarn.lock | 2 +- 12 files changed, 82 insertions(+), 1571 deletions(-) create mode 100644 packages/kbn-tinymath/BUILD.bazel rename packages/kbn-tinymath/{src => grammar}/grammar.pegjs (99%) rename packages/kbn-tinymath/{tinymath.d.ts => index.d.ts} (100%) delete mode 100644 packages/kbn-tinymath/src/grammar.js diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index 88a142e5b53c08..fc78729be5a692 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -64,4 +64,5 @@ yarn kbn watch-bazel - @elastic/datemath - @kbn/apm-utils - @kbn/config-schema +- @kbn/tinymath diff --git a/package.json b/package.json index 9b4958c30022c7..ff7f76df4aee55 100644 --- a/package.json +++ b/package.json @@ -138,7 +138,7 @@ "@kbn/server-http-tools": "link:packages/kbn-server-http-tools", "@kbn/server-route-repository": "link:packages/kbn-server-route-repository", "@kbn/std": "link:packages/kbn-std", - "@kbn/tinymath": "link:packages/kbn-tinymath", + "@kbn/tinymath": "link:bazel-bin/packages/kbn-tinymath/npm_module", "@kbn/ui-framework": "link:packages/kbn-ui-framework", "@kbn/ui-shared-deps": "link:packages/kbn-ui-shared-deps", "@kbn/utility-types": "link:packages/kbn-utility-types", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index aa66c96764718f..182013c356bb0b 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -5,6 +5,7 @@ filegroup( srcs = [ "//packages/elastic-datemath:build", "//packages/kbn-apm-utils:build", - "//packages/kbn-config-schema:build" + "//packages/kbn-config-schema:build", + "//packages/kbn-tinymath:build", ], ) diff --git a/packages/kbn-tinymath/BUILD.bazel b/packages/kbn-tinymath/BUILD.bazel new file mode 100644 index 00000000000000..9d521776fb4919 --- /dev/null +++ b/packages/kbn-tinymath/BUILD.bazel @@ -0,0 +1,71 @@ +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") +load("@npm//pegjs:index.bzl", "pegjs") + +PKG_BASE_NAME = "kbn-tinymath" +PKG_REQUIRE_NAME = "@kbn/tinymath" + +SOURCE_FILES = glob( + [ + "src/**/*", + ] +) + +TYPE_FILES = [ + "index.d.ts", +] + +SRCS = SOURCE_FILES + TYPE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md", +] + +DEPS = [ + "@npm//lodash", +] + +pegjs( + name = "grammar", + data = [ + ":grammar/grammar.pegjs" + ], + output_dir = True, + args = [ + "-o", + "$(@D)/index.js", + "./%s/grammar/grammar.pegjs" % package_name() + ], +) + +js_library( + name = PKG_BASE_NAME, + srcs = [ + ":srcs", + ":grammar" + ], + deps = DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + srcs = NPM_MODULE_EXTRA_FILES, + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-tinymath/src/grammar.pegjs b/packages/kbn-tinymath/grammar/grammar.pegjs similarity index 99% rename from packages/kbn-tinymath/src/grammar.pegjs rename to packages/kbn-tinymath/grammar/grammar.pegjs index 9cb92fa9374a2b..70f275776e45dc 100644 --- a/packages/kbn-tinymath/src/grammar.pegjs +++ b/packages/kbn-tinymath/grammar/grammar.pegjs @@ -107,7 +107,7 @@ String / [\'] value:(ValidChar)+ [\'] { return value.join(''); } / value:(ValidChar)+ { return value.join(''); } - + Argument = name:[a-zA-Z_]+ _ '=' _ value:(Number / String) _ { return { diff --git a/packages/kbn-tinymath/tinymath.d.ts b/packages/kbn-tinymath/index.d.ts similarity index 100% rename from packages/kbn-tinymath/tinymath.d.ts rename to packages/kbn-tinymath/index.d.ts diff --git a/packages/kbn-tinymath/package.json b/packages/kbn-tinymath/package.json index cc4fa0a64d9c32..915afda7ba2d2b 100644 --- a/packages/kbn-tinymath/package.json +++ b/packages/kbn-tinymath/package.json @@ -4,10 +4,5 @@ "license": "SSPL-1.0 OR Elastic License 2.0", "private": true, "main": "src/index.js", - "types": "tinymath.d.ts", - "scripts": { - "kbn:bootstrap": "yarn build", - "build": "../../node_modules/.bin/pegjs -o src/grammar.js src/grammar.pegjs" - }, - "dependencies": {} + "types": "index.d.ts" } \ No newline at end of file diff --git a/packages/kbn-tinymath/src/grammar.js b/packages/kbn-tinymath/src/grammar.js deleted file mode 100644 index 5454143530c398..00000000000000 --- a/packages/kbn-tinymath/src/grammar.js +++ /dev/null @@ -1,1555 +0,0 @@ -/* - * Generated by PEG.js 0.10.0. - * - * http://pegjs.org/ - */ - -"use strict"; - -function peg$subclass(child, parent) { - function ctor() { this.constructor = child; } - ctor.prototype = parent.prototype; - child.prototype = new ctor(); -} - -function peg$SyntaxError(message, expected, found, location) { - this.message = message; - this.expected = expected; - this.found = found; - this.location = location; - this.name = "SyntaxError"; - - if (typeof Error.captureStackTrace === "function") { - Error.captureStackTrace(this, peg$SyntaxError); - } -} - -peg$subclass(peg$SyntaxError, Error); - -peg$SyntaxError.buildMessage = function(expected, found) { - var DESCRIBE_EXPECTATION_FNS = { - literal: function(expectation) { - return "\"" + literalEscape(expectation.text) + "\""; - }, - - "class": function(expectation) { - var escapedParts = "", - i; - - for (i = 0; i < expectation.parts.length; i++) { - escapedParts += expectation.parts[i] instanceof Array - ? classEscape(expectation.parts[i][0]) + "-" + classEscape(expectation.parts[i][1]) - : classEscape(expectation.parts[i]); - } - - return "[" + (expectation.inverted ? "^" : "") + escapedParts + "]"; - }, - - any: function(expectation) { - return "any character"; - }, - - end: function(expectation) { - return "end of input"; - }, - - other: function(expectation) { - return expectation.description; - } - }; - - function hex(ch) { - return ch.charCodeAt(0).toString(16).toUpperCase(); - } - - function literalEscape(s) { - return s - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - .replace(/\0/g, '\\0') - .replace(/\t/g, '\\t') - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r') - .replace(/[\x00-\x0F]/g, function(ch) { return '\\x0' + hex(ch); }) - .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return '\\x' + hex(ch); }); - } - - function classEscape(s) { - return s - .replace(/\\/g, '\\\\') - .replace(/\]/g, '\\]') - .replace(/\^/g, '\\^') - .replace(/-/g, '\\-') - .replace(/\0/g, '\\0') - .replace(/\t/g, '\\t') - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r') - .replace(/[\x00-\x0F]/g, function(ch) { return '\\x0' + hex(ch); }) - .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return '\\x' + hex(ch); }); - } - - function describeExpectation(expectation) { - return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); - } - - function describeExpected(expected) { - var descriptions = new Array(expected.length), - i, j; - - for (i = 0; i < expected.length; i++) { - descriptions[i] = describeExpectation(expected[i]); - } - - descriptions.sort(); - - if (descriptions.length > 0) { - for (i = 1, j = 1; i < descriptions.length; i++) { - if (descriptions[i - 1] !== descriptions[i]) { - descriptions[j] = descriptions[i]; - j++; - } - } - descriptions.length = j; - } - - switch (descriptions.length) { - case 1: - return descriptions[0]; - - case 2: - return descriptions[0] + " or " + descriptions[1]; - - default: - return descriptions.slice(0, -1).join(", ") - + ", or " - + descriptions[descriptions.length - 1]; - } - } - - function describeFound(found) { - return found ? "\"" + literalEscape(found) + "\"" : "end of input"; - } - - return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; -}; - -function peg$parse(input, options) { - options = options !== void 0 ? options : {}; - - var peg$FAILED = {}, - - peg$startRuleFunctions = { start: peg$parsestart }, - peg$startRuleFunction = peg$parsestart, - - peg$c0 = peg$otherExpectation("whitespace"), - peg$c1 = /^[ \t\n\r]/, - peg$c2 = peg$classExpectation([" ", "\t", "\n", "\r"], false, false), - peg$c3 = /^[ ]/, - peg$c4 = peg$classExpectation([" "], false, false), - peg$c5 = /^["']/, - peg$c6 = peg$classExpectation(["\"", "'"], false, false), - peg$c7 = /^[A-Za-z_@.[\]\-]/, - peg$c8 = peg$classExpectation([["A", "Z"], ["a", "z"], "_", "@", ".", "[", "]", "-"], false, false), - peg$c9 = /^[0-9A-Za-z._@[\]\-]/, - peg$c10 = peg$classExpectation([["0", "9"], ["A", "Z"], ["a", "z"], ".", "_", "@", "[", "]", "-"], false, false), - peg$c11 = peg$otherExpectation("literal"), - peg$c12 = function(literal) { - return literal; - }, - peg$c13 = function(chars) { - return { - type: 'variable', - value: chars.join(''), - location: simpleLocation(location()), - text: text() - }; - }, - peg$c14 = function(rest) { - return { - type: 'variable', - value: rest.join(''), - location: simpleLocation(location()), - text: text() - }; - }, - peg$c15 = "+", - peg$c16 = peg$literalExpectation("+", false), - peg$c17 = "-", - peg$c18 = peg$literalExpectation("-", false), - peg$c19 = function(left, rest) { - return rest.reduce((acc, curr) => ({ - type: 'function', - name: curr[0] === '+' ? 'add' : 'subtract', - args: [acc, curr[1]], - location: simpleLocation(location()), - text: text() - }), left) - }, - peg$c20 = "*", - peg$c21 = peg$literalExpectation("*", false), - peg$c22 = "/", - peg$c23 = peg$literalExpectation("/", false), - peg$c24 = function(left, rest) { - return rest.reduce((acc, curr) => ({ - type: 'function', - name: curr[0] === '*' ? 'multiply' : 'divide', - args: [acc, curr[1]], - location: simpleLocation(location()), - text: text() - }), left) - }, - peg$c25 = "(", - peg$c26 = peg$literalExpectation("(", false), - peg$c27 = ")", - peg$c28 = peg$literalExpectation(")", false), - peg$c29 = function(expr) { - return expr - }, - peg$c30 = peg$otherExpectation("arguments"), - peg$c31 = ",", - peg$c32 = peg$literalExpectation(",", false), - peg$c33 = function(first, arg) {return arg}, - peg$c34 = function(first, rest) { - return [first].concat(rest); - }, - peg$c35 = /^["]/, - peg$c36 = peg$classExpectation(["\""], false, false), - peg$c37 = function(value) { return value.join(''); }, - peg$c38 = /^[']/, - peg$c39 = peg$classExpectation(["'"], false, false), - peg$c40 = /^[a-zA-Z_]/, - peg$c41 = peg$classExpectation([["a", "z"], ["A", "Z"], "_"], false, false), - peg$c42 = "=", - peg$c43 = peg$literalExpectation("=", false), - peg$c44 = function(name, value) { - return { - type: 'namedArgument', - name: name.join(''), - value: value, - location: simpleLocation(location()), - text: text() - }; - }, - peg$c45 = peg$otherExpectation("function"), - peg$c46 = /^[a-zA-Z_\-]/, - peg$c47 = peg$classExpectation([["a", "z"], ["A", "Z"], "_", "-"], false, false), - peg$c48 = function(name, args) { - return { - type: 'function', - name: name.join(''), - args: args || [], - location: simpleLocation(location()), - text: text() - }; - }, - peg$c49 = peg$otherExpectation("number"), - peg$c50 = function() { - return parseFloat(text()); - }, - peg$c51 = /^[eE]/, - peg$c52 = peg$classExpectation(["e", "E"], false, false), - peg$c53 = peg$otherExpectation("exponent"), - peg$c54 = ".", - peg$c55 = peg$literalExpectation(".", false), - peg$c56 = "0", - peg$c57 = peg$literalExpectation("0", false), - peg$c58 = /^[1-9]/, - peg$c59 = peg$classExpectation([["1", "9"]], false, false), - peg$c60 = /^[0-9]/, - peg$c61 = peg$classExpectation([["0", "9"]], false, false), - - peg$currPos = 0, - peg$savedPos = 0, - peg$posDetailsCache = [{ line: 1, column: 1 }], - peg$maxFailPos = 0, - peg$maxFailExpected = [], - peg$silentFails = 0, - - peg$result; - - if ("startRule" in options) { - if (!(options.startRule in peg$startRuleFunctions)) { - throw new Error("Can't start parsing from rule \"" + options.startRule + "\"."); - } - - peg$startRuleFunction = peg$startRuleFunctions[options.startRule]; - } - - function text() { - return input.substring(peg$savedPos, peg$currPos); - } - - function location() { - return peg$computeLocation(peg$savedPos, peg$currPos); - } - - function expected(description, location) { - location = location !== void 0 ? location : peg$computeLocation(peg$savedPos, peg$currPos) - - throw peg$buildStructuredError( - [peg$otherExpectation(description)], - input.substring(peg$savedPos, peg$currPos), - location - ); - } - - function error(message, location) { - location = location !== void 0 ? location : peg$computeLocation(peg$savedPos, peg$currPos) - - throw peg$buildSimpleError(message, location); - } - - function peg$literalExpectation(text, ignoreCase) { - return { type: "literal", text: text, ignoreCase: ignoreCase }; - } - - function peg$classExpectation(parts, inverted, ignoreCase) { - return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase }; - } - - function peg$anyExpectation() { - return { type: "any" }; - } - - function peg$endExpectation() { - return { type: "end" }; - } - - function peg$otherExpectation(description) { - return { type: "other", description: description }; - } - - function peg$computePosDetails(pos) { - var details = peg$posDetailsCache[pos], p; - - if (details) { - return details; - } else { - p = pos - 1; - while (!peg$posDetailsCache[p]) { - p--; - } - - details = peg$posDetailsCache[p]; - details = { - line: details.line, - column: details.column - }; - - while (p < pos) { - if (input.charCodeAt(p) === 10) { - details.line++; - details.column = 1; - } else { - details.column++; - } - - p++; - } - - peg$posDetailsCache[pos] = details; - return details; - } - } - - function peg$computeLocation(startPos, endPos) { - var startPosDetails = peg$computePosDetails(startPos), - endPosDetails = peg$computePosDetails(endPos); - - return { - start: { - offset: startPos, - line: startPosDetails.line, - column: startPosDetails.column - }, - end: { - offset: endPos, - line: endPosDetails.line, - column: endPosDetails.column - } - }; - } - - function peg$fail(expected) { - if (peg$currPos < peg$maxFailPos) { return; } - - if (peg$currPos > peg$maxFailPos) { - peg$maxFailPos = peg$currPos; - peg$maxFailExpected = []; - } - - peg$maxFailExpected.push(expected); - } - - function peg$buildSimpleError(message, location) { - return new peg$SyntaxError(message, null, null, location); - } - - function peg$buildStructuredError(expected, found, location) { - return new peg$SyntaxError( - peg$SyntaxError.buildMessage(expected, found), - expected, - found, - location - ); - } - - function peg$parsestart() { - var s0; - - s0 = peg$parseAddSubtract(); - - return s0; - } - - function peg$parse_() { - var s0, s1; - - peg$silentFails++; - s0 = []; - if (peg$c1.test(input.charAt(peg$currPos))) { - s1 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c2); } - } - while (s1 !== peg$FAILED) { - s0.push(s1); - if (peg$c1.test(input.charAt(peg$currPos))) { - s1 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c2); } - } - } - peg$silentFails--; - if (s0 === peg$FAILED) { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c0); } - } - - return s0; - } - - function peg$parseSpace() { - var s0; - - if (peg$c3.test(input.charAt(peg$currPos))) { - s0 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c4); } - } - - return s0; - } - - function peg$parseQuote() { - var s0; - - if (peg$c5.test(input.charAt(peg$currPos))) { - s0 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c6); } - } - - return s0; - } - - function peg$parseStartChar() { - var s0; - - if (peg$c7.test(input.charAt(peg$currPos))) { - s0 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c8); } - } - - return s0; - } - - function peg$parseValidChar() { - var s0; - - if (peg$c9.test(input.charAt(peg$currPos))) { - s0 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c10); } - } - - return s0; - } - - function peg$parseLiteral() { - var s0, s1, s2, s3; - - peg$silentFails++; - s0 = peg$currPos; - s1 = peg$parse_(); - if (s1 !== peg$FAILED) { - s2 = peg$parseNumber(); - if (s2 === peg$FAILED) { - s2 = peg$parseVariable(); - } - if (s2 !== peg$FAILED) { - s3 = peg$parse_(); - if (s3 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c12(s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - peg$silentFails--; - if (s0 === peg$FAILED) { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c11); } - } - - return s0; - } - - function peg$parseVariable() { - var s0, s1, s2, s3, s4, s5; - - s0 = peg$currPos; - s1 = peg$parse_(); - if (s1 !== peg$FAILED) { - s2 = peg$parseQuote(); - if (s2 !== peg$FAILED) { - s3 = []; - s4 = peg$parseValidChar(); - if (s4 === peg$FAILED) { - s4 = peg$parseSpace(); - } - while (s4 !== peg$FAILED) { - s3.push(s4); - s4 = peg$parseValidChar(); - if (s4 === peg$FAILED) { - s4 = peg$parseSpace(); - } - } - if (s3 !== peg$FAILED) { - s4 = peg$parseQuote(); - if (s4 !== peg$FAILED) { - s5 = peg$parse_(); - if (s5 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c13(s3); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = peg$parse_(); - if (s1 !== peg$FAILED) { - s2 = []; - s3 = peg$parseValidChar(); - if (s3 !== peg$FAILED) { - while (s3 !== peg$FAILED) { - s2.push(s3); - s3 = peg$parseValidChar(); - } - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - s3 = peg$parse_(); - if (s3 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c14(s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } - - return s0; - } - - function peg$parseAddSubtract() { - var s0, s1, s2, s3, s4, s5, s6; - - s0 = peg$currPos; - s1 = peg$parse_(); - if (s1 !== peg$FAILED) { - s2 = peg$parseMultiplyDivide(); - if (s2 !== peg$FAILED) { - s3 = []; - s4 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 43) { - s5 = peg$c15; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c16); } - } - if (s5 === peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 45) { - s5 = peg$c17; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c18); } - } - } - if (s5 !== peg$FAILED) { - s6 = peg$parseMultiplyDivide(); - if (s6 !== peg$FAILED) { - s5 = [s5, s6]; - s4 = s5; - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - while (s4 !== peg$FAILED) { - s3.push(s4); - s4 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 43) { - s5 = peg$c15; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c16); } - } - if (s5 === peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 45) { - s5 = peg$c17; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c18); } - } - } - if (s5 !== peg$FAILED) { - s6 = peg$parseMultiplyDivide(); - if (s6 !== peg$FAILED) { - s5 = [s5, s6]; - s4 = s5; - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } - if (s3 !== peg$FAILED) { - s4 = peg$parse_(); - if (s4 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c19(s2, s3); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parseMultiplyDivide() { - var s0, s1, s2, s3, s4, s5, s6; - - s0 = peg$currPos; - s1 = peg$parse_(); - if (s1 !== peg$FAILED) { - s2 = peg$parseFactor(); - if (s2 !== peg$FAILED) { - s3 = []; - s4 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 42) { - s5 = peg$c20; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c21); } - } - if (s5 === peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 47) { - s5 = peg$c22; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c23); } - } - } - if (s5 !== peg$FAILED) { - s6 = peg$parseFactor(); - if (s6 !== peg$FAILED) { - s5 = [s5, s6]; - s4 = s5; - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - while (s4 !== peg$FAILED) { - s3.push(s4); - s4 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 42) { - s5 = peg$c20; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c21); } - } - if (s5 === peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 47) { - s5 = peg$c22; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c23); } - } - } - if (s5 !== peg$FAILED) { - s6 = peg$parseFactor(); - if (s6 !== peg$FAILED) { - s5 = [s5, s6]; - s4 = s5; - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } else { - peg$currPos = s4; - s4 = peg$FAILED; - } - } - if (s3 !== peg$FAILED) { - s4 = peg$parse_(); - if (s4 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c24(s2, s3); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parseFactor() { - var s0; - - s0 = peg$parseGroup(); - if (s0 === peg$FAILED) { - s0 = peg$parseFunction(); - if (s0 === peg$FAILED) { - s0 = peg$parseLiteral(); - } - } - - return s0; - } - - function peg$parseGroup() { - var s0, s1, s2, s3, s4, s5, s6, s7; - - s0 = peg$currPos; - s1 = peg$parse_(); - if (s1 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 40) { - s2 = peg$c25; - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c26); } - } - if (s2 !== peg$FAILED) { - s3 = peg$parse_(); - if (s3 !== peg$FAILED) { - s4 = peg$parseAddSubtract(); - if (s4 !== peg$FAILED) { - s5 = peg$parse_(); - if (s5 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 41) { - s6 = peg$c27; - peg$currPos++; - } else { - s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c28); } - } - if (s6 !== peg$FAILED) { - s7 = peg$parse_(); - if (s7 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c29(s4); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parseArgument_List() { - var s0, s1, s2, s3, s4, s5, s6, s7; - - peg$silentFails++; - s0 = peg$currPos; - s1 = peg$parseArgument(); - if (s1 !== peg$FAILED) { - s2 = []; - s3 = peg$currPos; - s4 = peg$parse_(); - if (s4 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 44) { - s5 = peg$c31; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c32); } - } - if (s5 !== peg$FAILED) { - s6 = peg$parse_(); - if (s6 !== peg$FAILED) { - s7 = peg$parseArgument(); - if (s7 !== peg$FAILED) { - peg$savedPos = s3; - s4 = peg$c33(s1, s7); - s3 = s4; - } else { - peg$currPos = s3; - s3 = peg$FAILED; - } - } else { - peg$currPos = s3; - s3 = peg$FAILED; - } - } else { - peg$currPos = s3; - s3 = peg$FAILED; - } - } else { - peg$currPos = s3; - s3 = peg$FAILED; - } - while (s3 !== peg$FAILED) { - s2.push(s3); - s3 = peg$currPos; - s4 = peg$parse_(); - if (s4 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 44) { - s5 = peg$c31; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c32); } - } - if (s5 !== peg$FAILED) { - s6 = peg$parse_(); - if (s6 !== peg$FAILED) { - s7 = peg$parseArgument(); - if (s7 !== peg$FAILED) { - peg$savedPos = s3; - s4 = peg$c33(s1, s7); - s3 = s4; - } else { - peg$currPos = s3; - s3 = peg$FAILED; - } - } else { - peg$currPos = s3; - s3 = peg$FAILED; - } - } else { - peg$currPos = s3; - s3 = peg$FAILED; - } - } else { - peg$currPos = s3; - s3 = peg$FAILED; - } - } - if (s2 !== peg$FAILED) { - s3 = peg$parse_(); - if (s3 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 44) { - s4 = peg$c31; - peg$currPos++; - } else { - s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c32); } - } - if (s4 === peg$FAILED) { - s4 = null; - } - if (s4 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c34(s1, s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - peg$silentFails--; - if (s0 === peg$FAILED) { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c30); } - } - - return s0; - } - - function peg$parseString() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - if (peg$c35.test(input.charAt(peg$currPos))) { - s1 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c36); } - } - if (s1 !== peg$FAILED) { - s2 = []; - s3 = peg$parseValidChar(); - if (s3 !== peg$FAILED) { - while (s3 !== peg$FAILED) { - s2.push(s3); - s3 = peg$parseValidChar(); - } - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - if (peg$c35.test(input.charAt(peg$currPos))) { - s3 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c36); } - } - if (s3 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c37(s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (peg$c38.test(input.charAt(peg$currPos))) { - s1 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c39); } - } - if (s1 !== peg$FAILED) { - s2 = []; - s3 = peg$parseValidChar(); - if (s3 !== peg$FAILED) { - while (s3 !== peg$FAILED) { - s2.push(s3); - s3 = peg$parseValidChar(); - } - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - if (peg$c38.test(input.charAt(peg$currPos))) { - s3 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c39); } - } - if (s3 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c37(s2); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = []; - s2 = peg$parseValidChar(); - if (s2 !== peg$FAILED) { - while (s2 !== peg$FAILED) { - s1.push(s2); - s2 = peg$parseValidChar(); - } - } else { - s1 = peg$FAILED; - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c37(s1); - } - s0 = s1; - } - } - - return s0; - } - - function peg$parseArgument() { - var s0, s1, s2, s3, s4, s5, s6; - - s0 = peg$currPos; - s1 = []; - if (peg$c40.test(input.charAt(peg$currPos))) { - s2 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c41); } - } - if (s2 !== peg$FAILED) { - while (s2 !== peg$FAILED) { - s1.push(s2); - if (peg$c40.test(input.charAt(peg$currPos))) { - s2 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c41); } - } - } - } else { - s1 = peg$FAILED; - } - if (s1 !== peg$FAILED) { - s2 = peg$parse_(); - if (s2 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 61) { - s3 = peg$c42; - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c43); } - } - if (s3 !== peg$FAILED) { - s4 = peg$parse_(); - if (s4 !== peg$FAILED) { - s5 = peg$parseNumber(); - if (s5 === peg$FAILED) { - s5 = peg$parseString(); - } - if (s5 !== peg$FAILED) { - s6 = peg$parse_(); - if (s6 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c44(s1, s5); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$parseAddSubtract(); - } - - return s0; - } - - function peg$parseFunction() { - var s0, s1, s2, s3, s4, s5, s6, s7, s8; - - peg$silentFails++; - s0 = peg$currPos; - s1 = peg$parse_(); - if (s1 !== peg$FAILED) { - s2 = []; - if (peg$c46.test(input.charAt(peg$currPos))) { - s3 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c47); } - } - if (s3 !== peg$FAILED) { - while (s3 !== peg$FAILED) { - s2.push(s3); - if (peg$c46.test(input.charAt(peg$currPos))) { - s3 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c47); } - } - } - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 40) { - s3 = peg$c25; - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c26); } - } - if (s3 !== peg$FAILED) { - s4 = peg$parse_(); - if (s4 !== peg$FAILED) { - s5 = peg$parseArgument_List(); - if (s5 === peg$FAILED) { - s5 = null; - } - if (s5 !== peg$FAILED) { - s6 = peg$parse_(); - if (s6 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 41) { - s7 = peg$c27; - peg$currPos++; - } else { - s7 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c28); } - } - if (s7 !== peg$FAILED) { - s8 = peg$parse_(); - if (s8 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c48(s2, s5); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - peg$silentFails--; - if (s0 === peg$FAILED) { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c45); } - } - - return s0; - } - - function peg$parseNumber() { - var s0, s1, s2, s3, s4; - - peg$silentFails++; - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 45) { - s1 = peg$c17; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c18); } - } - if (s1 === peg$FAILED) { - s1 = null; - } - if (s1 !== peg$FAILED) { - s2 = peg$parseInteger(); - if (s2 !== peg$FAILED) { - s3 = peg$parseFraction(); - if (s3 === peg$FAILED) { - s3 = null; - } - if (s3 !== peg$FAILED) { - s4 = peg$parseExp(); - if (s4 === peg$FAILED) { - s4 = null; - } - if (s4 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c50(); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - peg$silentFails--; - if (s0 === peg$FAILED) { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c49); } - } - - return s0; - } - - function peg$parseE() { - var s0; - - if (peg$c51.test(input.charAt(peg$currPos))) { - s0 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c52); } - } - - return s0; - } - - function peg$parseExp() { - var s0, s1, s2, s3, s4; - - peg$silentFails++; - s0 = peg$currPos; - s1 = peg$parseE(); - if (s1 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 45) { - s2 = peg$c17; - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c18); } - } - if (s2 === peg$FAILED) { - s2 = null; - } - if (s2 !== peg$FAILED) { - s3 = []; - s4 = peg$parseDigit(); - if (s4 !== peg$FAILED) { - while (s4 !== peg$FAILED) { - s3.push(s4); - s4 = peg$parseDigit(); - } - } else { - s3 = peg$FAILED; - } - if (s3 !== peg$FAILED) { - s1 = [s1, s2, s3]; - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - peg$silentFails--; - if (s0 === peg$FAILED) { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c53); } - } - - return s0; - } - - function peg$parseFraction() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 46) { - s1 = peg$c54; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c55); } - } - if (s1 !== peg$FAILED) { - s2 = []; - s3 = peg$parseDigit(); - if (s3 !== peg$FAILED) { - while (s3 !== peg$FAILED) { - s2.push(s3); - s3 = peg$parseDigit(); - } - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - s1 = [s1, s2]; - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parseInteger() { - var s0, s1, s2, s3; - - if (input.charCodeAt(peg$currPos) === 48) { - s0 = peg$c56; - peg$currPos++; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c57); } - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (peg$c58.test(input.charAt(peg$currPos))) { - s1 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c59); } - } - if (s1 !== peg$FAILED) { - s2 = []; - s3 = peg$parseDigit(); - while (s3 !== peg$FAILED) { - s2.push(s3); - s3 = peg$parseDigit(); - } - if (s2 !== peg$FAILED) { - s1 = [s1, s2]; - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } - - return s0; - } - - function peg$parseDigit() { - var s0; - - if (peg$c60.test(input.charAt(peg$currPos))) { - s0 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c61); } - } - - return s0; - } - - - function simpleLocation (location) { - // Returns an object representing the position of the function within the expression, - // demarcated by the position of its first character and last character. We calculate these values - // using the offset because the expression could span multiple lines, and we don't want to deal - // with column and line values. - return { - min: location.start.offset, - max: location.end.offset - } - } - - - peg$result = peg$startRuleFunction(); - - if (peg$result !== peg$FAILED && peg$currPos === input.length) { - return peg$result; - } else { - if (peg$result !== peg$FAILED && peg$currPos < input.length) { - peg$fail(peg$endExpectation()); - } - - throw peg$buildStructuredError( - peg$maxFailExpected, - peg$maxFailPos < input.length ? input.charAt(peg$maxFailPos) : null, - peg$maxFailPos < input.length - ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) - : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) - ); - } -} - -module.exports = { - SyntaxError: peg$SyntaxError, - parse: peg$parse -}; diff --git a/packages/kbn-tinymath/src/index.js b/packages/kbn-tinymath/src/index.js index 4db7df9c573156..9f1bb7b8514634 100644 --- a/packages/kbn-tinymath/src/index.js +++ b/packages/kbn-tinymath/src/index.js @@ -7,7 +7,8 @@ */ const { get } = require('lodash'); -const { parse: parseFn } = require('./grammar'); +// eslint-disable-next-line import/no-unresolved +const { parse: parseFn } = require('../grammar'); const { functions: includedFunctions } = require('./functions'); module.exports = { parse, evaluate, interpret }; diff --git a/packages/kbn-tinymath/test/library.test.js b/packages/kbn-tinymath/test/library.test.js index d11822625b98f5..5ddf1b049b8d4f 100644 --- a/packages/kbn-tinymath/test/library.test.js +++ b/packages/kbn-tinymath/test/library.test.js @@ -11,7 +11,7 @@ Need tests for spacing, etc */ -import { evaluate, parse } from '..'; +import { evaluate, parse } from '@kbn/tinymath'; function variableEqual(value) { return expect.objectContaining({ type: 'variable', value }); diff --git a/packages/kbn-tinymath/tsconfig.json b/packages/kbn-tinymath/tsconfig.json index 62a7376efdfa6d..73133b7318a0d6 100644 --- a/packages/kbn-tinymath/tsconfig.json +++ b/packages/kbn-tinymath/tsconfig.json @@ -1,7 +1,4 @@ { "extends": "../../tsconfig.base.json", - "compilerOptions": { - "tsBuildInfoFile": "../../build/tsbuildinfo/packages/kbn-tinymath" - }, - "include": ["tinymath.d.ts"] + "include": ["index.d.ts"] } diff --git a/yarn.lock b/yarn.lock index c0c481e5411263..2aaf94250b966b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2740,7 +2740,7 @@ version "0.0.0" uid "" -"@kbn/tinymath@link:packages/kbn-tinymath": +"@kbn/tinymath@link:bazel-bin/packages/kbn-tinymath/npm_module": version "0.0.0" uid "" From 9602896f9e25d04371bd7919d797de370e14de9e Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 14 Apr 2021 21:06:28 +0200 Subject: [PATCH 90/90] Discover: Limit document table rendering (#96765) --- src/plugins/discover/common/index.ts | 1 + .../angular/helpers/row_formatter.test.ts | 17 +++++ .../angular/helpers/row_formatter.ts | 9 ++- .../discover_grid/discover_grid.tsx | 6 +- .../get_render_cell_value.test.tsx | 75 ++++++++++++++++--- .../discover_grid/get_render_cell_value.tsx | 7 +- src/plugins/discover/server/ui_settings.ts | 12 +++ .../server/collectors/management/schema.ts | 4 + .../server/collectors/management/types.ts | 1 + src/plugins/telemetry/schema/oss_plugins.json | 18 +++-- 10 files changed, 125 insertions(+), 25 deletions(-) diff --git a/src/plugins/discover/common/index.ts b/src/plugins/discover/common/index.ts index 45cc95ee40804d..dd7f9c41a223d5 100644 --- a/src/plugins/discover/common/index.ts +++ b/src/plugins/discover/common/index.ts @@ -18,3 +18,4 @@ export const CONTEXT_TIE_BREAKER_FIELDS_SETTING = 'context:tieBreakerFields'; export const DOC_TABLE_LEGACY = 'doc_table:legacy'; export const MODIFY_COLUMNS_ON_SWITCH = 'discover:modifyColumnsOnSwitch'; export const SEARCH_FIELDS_FROM_SOURCE = 'discover:searchFieldsFromSource'; +export const MAX_DOC_FIELDS_DISPLAYED = 'discover:maxDocFieldsDisplayed'; diff --git a/src/plugins/discover/public/application/angular/helpers/row_formatter.test.ts b/src/plugins/discover/public/application/angular/helpers/row_formatter.test.ts index 4c6b9002ce867e..ca5cdbd8086061 100644 --- a/src/plugins/discover/public/application/angular/helpers/row_formatter.test.ts +++ b/src/plugins/discover/public/application/angular/helpers/row_formatter.test.ts @@ -10,6 +10,7 @@ import { formatRow, formatTopLevelObject } from './row_formatter'; import { stubbedSavedObjectIndexPattern } from '../../../__mocks__/stubbed_saved_object_index_pattern'; import { IndexPattern } from '../../../../../data/common/index_patterns/index_patterns'; import { fieldFormatsMock } from '../../../../../data/common/field_formats/mocks'; +import { setServices } from '../../../kibana_services'; describe('Row formatter', () => { const hit = { @@ -58,6 +59,11 @@ describe('Row formatter', () => { beforeEach(() => { // @ts-expect-error indexPattern.formatHit = formatHitMock; + setServices({ + uiSettings: { + get: () => 100, + }, + }); }); it('formats document properly', () => { @@ -66,6 +72,17 @@ describe('Row formatter', () => { ); }); + it('limits number of rendered items', () => { + setServices({ + uiSettings: { + get: () => 1, + }, + }); + expect(formatRow(hit, indexPattern).trim()).toMatchInlineSnapshot( + `"
also:
with \\\\"quotes\\\\" or 'single qoutes'
"` + ); + }); + it('formats document with highlighted fields first', () => { expect( formatRow({ ...hit, highlight: { number: '42' } }, indexPattern).trim() diff --git a/src/plugins/discover/public/application/angular/helpers/row_formatter.ts b/src/plugins/discover/public/application/angular/helpers/row_formatter.ts index 02902b06347974..b219dda19e10a8 100644 --- a/src/plugins/discover/public/application/angular/helpers/row_formatter.ts +++ b/src/plugins/discover/public/application/angular/helpers/row_formatter.ts @@ -7,7 +7,8 @@ */ import { template } from 'lodash'; -import { IndexPattern } from '../../../kibana_services'; +import { MAX_DOC_FIELDS_DISPLAYED } from '../../../../common'; +import { getServices, IndexPattern } from '../../../kibana_services'; function noWhiteSpace(html: string) { const TAGS_WITH_WS = />\s+, indexPattern: IndexPattern) const pairs = highlights[key] ? highlightPairs : sourcePairs; pairs.push([displayKey ? displayKey : key, val]); }); - return doTemplate({ defPairs: [...highlightPairs, ...sourcePairs] }); + const maxEntries = getServices().uiSettings.get(MAX_DOC_FIELDS_DISPLAYED); + return doTemplate({ defPairs: [...highlightPairs, ...sourcePairs].slice(0, maxEntries) }); }; export const formatTopLevelObject = ( @@ -67,5 +69,6 @@ export const formatTopLevelObject = ( const pairs = highlights[key] ? highlightPairs : sourcePairs; pairs.push([displayKey ? displayKey : key, formatted]); }); - return doTemplate({ defPairs: [...highlightPairs, ...sourcePairs] }); + const maxEntries = getServices().uiSettings.get(MAX_DOC_FIELDS_DISPLAYED); + return doTemplate({ defPairs: [...highlightPairs, ...sourcePairs].slice(0, maxEntries) }); }; diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx index 300c40a28c6626..be38f166fa1c0b 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx @@ -37,6 +37,7 @@ import { defaultPageSize, gridStyle, pageSizeArr, toolbarVisibility } from './co import { DiscoverServices } from '../../../build_services'; import { getDisplayedColumns } from '../../helpers/columns'; import { KibanaContextProvider } from '../../../../../kibana_react/public'; +import { MAX_DOC_FIELDS_DISPLAYED } from '../../../../common'; import { DiscoverGridDocumentToolbarBtn, getDocId } from './discover_grid_document_selection'; interface SortObj { @@ -223,9 +224,10 @@ export const DiscoverGrid = ({ indexPattern, displayedRows, displayedRows ? displayedRows.map((hit) => indexPattern.flattenHit(hit)) : [], - useNewFieldsApi + useNewFieldsApi, + services.uiSettings.get(MAX_DOC_FIELDS_DISPLAYED) ), - [displayedRows, indexPattern, useNewFieldsApi] + [displayedRows, indexPattern, useNewFieldsApi, services.uiSettings] ); /** diff --git a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx index 74cf083d82653f..b7e37a28fe539d 100644 --- a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx @@ -74,7 +74,8 @@ describe('Discover grid cell rendering', function () { indexPatternMock, rowsSource, rowsSource.map((row) => indexPatternMock.flattenHit(row)), - false + false, + 100 ); const component = shallow( indexPatternMock.flattenHit(row)), - false + false, + 100 ); const component = shallow( indexPatternMock.flattenHit(row)), - false + false, + 100 ); const component = shallow( indexPatternMock.flattenHit(row)), - true + true, + 100 ); const component = shallow( { + const DiscoverGridCellValue = getRenderCellValueFn( + indexPatternMock, + rowsFields, + rowsFields.map((row) => indexPatternMock.flattenHit(row)), + true, + // this is the number of rendered items + 1 + ); + const component = shallow( + + ); + expect(component).toMatchInlineSnapshot(` + + + extension + + + + `); + }); + it('renders fields-based column correctly when isDetails is set to true', () => { const DiscoverGridCellValue = getRenderCellValueFn( indexPatternMock, rowsFields, rowsFields.map((row) => indexPatternMock.flattenHit(row)), - true + true, + 100 ); const component = shallow( indexPatternMock.flattenHit(row)), - true + true, + 100 ); const component = shallow( indexPatternMock.flattenHit(row)), - true + true, + 100 ); const component = shallow( indexPatternMock.flattenHit(row)), - true + true, + 100 ); const component = shallow( indexPatternMock.flattenHit(row)), - true + true, + 100 ); const component = shallow( indexPatternMock.flattenHit(row)), - false + false, + 100 ); const component = shallow( indexPatternMock.flattenHit(row)), - false + false, + 100 ); const component = shallow( >, - useNewFieldsApi: boolean + useNewFieldsApi: boolean, + maxDocFieldsDisplayed: number ) => ({ rowIndex, columnId, isDetails, setCellProps }: EuiDataGridCellValueElementProps) => { const row = rows ? rows[rowIndex] : undefined; const rowFlattened = rowsFlattened @@ -98,7 +99,7 @@ export const getRenderCellValueFn = ( return ( - {[...highlightPairs, ...sourcePairs].map(([key, value]) => ( + {[...highlightPairs, ...sourcePairs].slice(0, maxDocFieldsDisplayed).map(([key, value]) => ( {key} - {[...highlightPairs, ...sourcePairs].map(([key, value]) => ( + {[...highlightPairs, ...sourcePairs].slice(0, maxDocFieldsDisplayed).map(([key, value]) => ( {key} = { @@ -38,6 +39,17 @@ export const uiSettings: Record = { category: ['discover'], schema: schema.arrayOf(schema.string()), }, + [MAX_DOC_FIELDS_DISPLAYED]: { + name: i18n.translate('discover.advancedSettings.maxDocFieldsDisplayedTitle', { + defaultMessage: 'Maximum document fields displayed', + }), + value: 200, + description: i18n.translate('discover.advancedSettings.maxDocFieldsDisplayedText', { + defaultMessage: 'Maximum number of fields rendered in the document column', + }), + category: ['discover'], + schema: schema.number(), + }, [SAMPLE_SIZE_SETTING]: { name: i18n.translate('discover.advancedSettings.sampleSizeTitle', { defaultMessage: 'Number of rows', diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index fcdd00380755fe..142bcef521c15f 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -189,6 +189,10 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'long', _meta: { description: 'Non-default value of setting.' }, }, + 'discover:maxDocFieldsDisplayed': { + type: 'long', + _meta: { description: 'Non-default value of setting.' }, + }, defaultColumns: { type: 'array', items: { diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index 613ada418c6e75..b457adecc1a79a 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -28,6 +28,7 @@ export interface UsageStats { 'doc_table:legacy': boolean; 'discover:modifyColumnsOnSwitch': boolean; 'discover:searchFieldsFromSource': boolean; + 'discover:maxDocFieldsDisplayed': number; 'securitySolution:rulesTableRefresh': string; 'apm:enableSignificantTerms': boolean; 'apm:enableServiceOverview': boolean; diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 56b7d98deaef89..2659fffa0bd9d3 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -7797,6 +7797,12 @@ "description": "Non-default value of setting." } }, + "discover:maxDocFieldsDisplayed": { + "type": "long", + "_meta": { + "description": "Non-default value of setting." + } + }, "defaultColumns": { "type": "array", "items": { @@ -8136,6 +8142,12 @@ "description": "Non-default value of setting." } }, + "observability:enableInspectEsQueries": { + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } + }, "banners:placement": { "type": "keyword", "_meta": { @@ -8160,12 +8172,6 @@ "description": "Non-default value of setting." } }, - "observability:enableInspectEsQueries": { - "type": "boolean", - "_meta": { - "description": "Non-default value of setting." - } - }, "labs:presentation:unifiedToolbar": { "type": "boolean", "_meta": {