From daec70ba1c56bc0b7097d22b80fb7afa620b67ba Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Fri, 8 Jul 2022 11:55:10 +0100 Subject: [PATCH 01/47] Refactoring all hosts to use useSearchStrategy (#135668) * useSearchStrategy * unit tests * update unit tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../hosts/containers/hosts/index.test.tsx | 64 +++++++- .../public/hosts/containers/hosts/index.tsx | 142 ++++++------------ 2 files changed, 106 insertions(+), 100 deletions(-) diff --git a/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.test.tsx index 8047f5699e2084..1fcd9318753cd6 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.test.tsx @@ -9,22 +9,72 @@ import { act, renderHook } from '@testing-library/react-hooks'; import { TestProviders } from '../../../common/mock'; import { useAllHost } from '.'; import { HostsType } from '../../store/model'; +import { useSearchStrategy } from '../../../common/containers/use_search_strategy'; + +jest.mock('../../../common/containers/use_search_strategy', () => ({ + useSearchStrategy: jest.fn(), +})); +const mockUseSearchStrategy = useSearchStrategy as jest.Mock; +const mockSearch = jest.fn(); + +const props = { + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['auditbeat-*'], + skip: false, + startDate: '2020-07-07T08:20:18.966Z', + type: HostsType.page, +}; describe('useAllHost', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseSearchStrategy.mockReturnValue({ + loading: false, + result: { + edges: [], + totalCount: -1, + pageInfo: { + activePage: 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + }, + search: mockSearch, + refetch: jest.fn(), + inspect: {}, + }); + }); + + it('runs search', () => { + renderHook(() => useAllHost(props), { + wrapper: TestProviders, + }); + + expect(mockSearch).toHaveBeenCalled(); + }); + + it('does not run search when skip = true', () => { + const localProps = { + ...props, + skip: true, + }; + renderHook(() => useAllHost(localProps), { + wrapper: TestProviders, + }); + + expect(mockSearch).not.toHaveBeenCalled(); + }); + it('skip = true will cancel any running request', () => { - const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); const localProps = { - startDate: '2020-07-07T08:20:18.966Z', - endDate: '2020-07-08T08:20:18.966Z', - indexNames: ['cool'], - type: HostsType.page, - skip: false, + ...props, }; const { rerender } = renderHook(() => useAllHost(localProps), { wrapper: TestProviders, }); localProps.skip = true; act(() => rerender()); - expect(abortSpy).toHaveBeenCalledTimes(4); + expect(mockUseSearchStrategy).toHaveBeenCalledTimes(3); + expect(mockUseSearchStrategy.mock.calls[2][0].abort).toEqual(true); }); }); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx index 6d4b3117129539..de7f1d3e788a71 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx @@ -6,14 +6,10 @@ */ import deepEqual from 'fast-deep-equal'; -import { noop } from 'lodash/fp'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Subscription } from 'rxjs'; +import { useCallback, useEffect, useMemo, useState } from 'react'; -import { isCompleteResponse, isErrorResponse } from '@kbn/data-plugin/common'; import { inputsModel, State } from '../../../common/store'; import { createFilter } from '../../../common/containers/helpers'; -import { useKibana } from '../../../common/lib/kibana'; import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { hostsModel, hostsSelectors } from '../../store'; import { generateTablePaginationOptions } from '../../../common/components/paginated_table/helpers'; @@ -23,14 +19,12 @@ import { DocValueFields, HostsQueries, HostsRequestOptions, - HostsStrategyResponse, } from '../../../../common/search_strategy'; import { ESTermQuery } from '../../../../common/typed_json'; import * as i18n from './translations'; -import { getInspectResponse } from '../../../helpers'; import { InspectResponse } from '../../../types'; -import { useAppToasts } from '../../../common/hooks/use_app_toasts'; +import { useSearchStrategy } from '../../../common/containers/use_search_strategy'; export const ID = 'hostsAllQuery'; @@ -50,8 +44,8 @@ export interface HostsArgs { interface UseAllHost { docValueFields?: DocValueFields[]; - filterQuery?: ESTermQuery | string; endDate: string; + filterQuery?: ESTermQuery | string; indexNames: string[]; skip?: boolean; startDate: string; @@ -60,8 +54,8 @@ interface UseAllHost { export const useAllHost = ({ docValueFields, - filterQuery, endDate, + filterQuery, indexNames, skip = false, startDate, @@ -71,13 +65,8 @@ export const useAllHost = ({ const { activePage, direction, limit, sortField } = useDeepEqualSelector((state: State) => getHostsSelector(state, type) ); - const { data } = useKibana().services; - const refetch = useRef(noop); - const abortCtrl = useRef(new AbortController()); - const searchSubscription = useRef(new Subscription()); - const [loading, setLoading] = useState(false); + const [hostsRequest, setHostRequest] = useState(null); - const { addError, addWarning } = useAppToasts(); const wrappedLoadMore = useCallback( (newActivePage: number) => { @@ -95,73 +84,50 @@ export const useAllHost = ({ [limit] ); - const [hostsResponse, setHostsResponse] = useState({ - endDate, - hosts: [], - id: ID, - inspect: { - dsl: [], - response: [], + const { + loading, + result: response, + search, + refetch, + inspect, + } = useSearchStrategy({ + factoryQueryType: HostsQueries.hosts, + initialResult: { + edges: [], + totalCount: -1, + pageInfo: { + activePage: 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, }, - isInspected: false, - loadPage: wrappedLoadMore, - pageInfo: { - activePage: 0, - fakeTotalCount: 0, - showMorePagesIndicator: false, - }, - refetch: refetch.current, - startDate, - totalCount: -1, + errorMessage: i18n.FAIL_ALL_HOST, + abort: skip, }); - const hostsSearch = useCallback( - (request: HostsRequestOptions | null) => { - if (request == null || skip) { - return; - } - - const asyncSearch = async () => { - abortCtrl.current = new AbortController(); - setLoading(true); - - searchSubscription.current = data.search - .search(request, { - strategy: 'securitySolutionSearchStrategy', - abortSignal: abortCtrl.current.signal, - }) - .subscribe({ - next: (response) => { - if (isCompleteResponse(response)) { - setLoading(false); - setHostsResponse((prevResponse) => ({ - ...prevResponse, - hosts: response.edges, - inspect: getInspectResponse(response, prevResponse.inspect), - pageInfo: response.pageInfo, - refetch: refetch.current, - totalCount: response.totalCount, - })); - searchSubscription.current.unsubscribe(); - } else if (isErrorResponse(response)) { - setLoading(false); - addWarning(i18n.ERROR_ALL_HOST); - searchSubscription.current.unsubscribe(); - } - }, - error: (msg) => { - setLoading(false); - addError(msg, { title: i18n.FAIL_ALL_HOST }); - searchSubscription.current.unsubscribe(); - }, - }); - }; - searchSubscription.current.unsubscribe(); - abortCtrl.current.abort(); - asyncSearch(); - refetch.current = asyncSearch; - }, - [data.search, addError, addWarning, skip] + const hostsResponse = useMemo( + () => ({ + endDate, + hosts: response.edges, + id: ID, + inspect, + isInspected: false, + loadPage: wrappedLoadMore, + pageInfo: response.pageInfo, + refetch, + startDate, + totalCount: response.totalCount, + }), + [ + endDate, + inspect, + refetch, + response.edges, + response.pageInfo, + response.totalCount, + startDate, + wrappedLoadMore, + ] ); useEffect(() => { @@ -201,20 +167,10 @@ export const useAllHost = ({ ]); useEffect(() => { - hostsSearch(hostsRequest); - return () => { - searchSubscription.current.unsubscribe(); - abortCtrl.current.abort(); - }; - }, [hostsRequest, hostsSearch]); - - useEffect(() => { - if (skip) { - setLoading(false); - searchSubscription.current.unsubscribe(); - abortCtrl.current.abort(); + if (!skip && hostsRequest) { + search(hostsRequest); } - }, [skip]); + }, [hostsRequest, search, skip]); return [loading, hostsResponse]; }; From 188b9e094d3800799240ca49e56e0735853a005f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20FOUCRET?= Date: Fri, 8 Jul 2022 13:15:46 +0200 Subject: [PATCH 02/47] [App Search][Nested Fields / Object fields]Use field capabilities (#135951) --- .../build_search_ui_config.test.ts | 27 ++++++---- .../build_search_ui_config.ts | 52 ++++++++----------- .../search_experience/customization_modal.tsx | 16 ++++-- .../search_experience/search_experience.tsx | 2 +- .../components/engine/engine_logic.test.ts | 13 +++++ .../app_search/components/engine/types.ts | 8 ++- .../components/result/result_token.tsx | 1 + .../applications/shared/schema/types.ts | 19 +++++++ 8 files changed, 91 insertions(+), 47 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.test.ts index 6cd55be21f984a..919dcdb354980b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.test.ts @@ -15,11 +15,23 @@ describe('buildSearchUIConfig', () => { it('builds a configuration object for Search UI', () => { const connector = {}; const schema = { - foo: SchemaType.Text, - bar: SchemaType.Number, + foo: { + type: SchemaType.Text, + capabilities: { + snippet: true, + facet: true, + }, + }, + bar: { + type: SchemaType.Number, + capabilities: { + snippet: false, + facet: false, + }, + }, }; const fields = { - filterFields: ['fieldA', 'fieldB'], + filterFields: ['foo', 'bar'], sortFields: [], }; @@ -32,13 +44,9 @@ describe('buildSearchUIConfig', () => { sortField: 'id', }, searchQuery: { - disjunctiveFacets: ['fieldA', 'fieldB'], + disjunctiveFacets: ['foo'], facets: { - fieldA: { - size: 30, - type: 'value', - }, - fieldB: { + foo: { size: 30, type: 'value', }, @@ -50,7 +58,6 @@ describe('buildSearchUIConfig', () => { foo: { raw: {}, snippet: { - fallback: true, size: 300, }, }, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.ts index 1edcc3cdfc27a6..97a8956dee75bf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.ts @@ -7,26 +7,34 @@ import type { APIConnector, SortDirection } from '@elastic/search-ui'; -import { Schema } from '../../../../shared/schema/types'; +import { SchemaType, AdvancedSchema } from '../../../../shared/schema/types'; import { Fields } from './types'; export const buildSearchUIConfig = ( apiConnector: APIConnector, - schema: Schema, + schema: AdvancedSchema, fields: Fields, initialState = { sortDirection: 'desc' as SortDirection, sortField: 'id' } ) => { - const facets = fields.filterFields.reduce((facetsConfig, fieldName) => { - // Geolocation fields do not support value facets https://www.elastic.co/guide/en/app-search/current/facets.html - if (schema[fieldName] === 'geolocation') { - return facetsConfig; - } - return { - ...facetsConfig, - [fieldName]: { type: 'value', size: 30 }, - }; - }, {}); + const facets = fields.filterFields + .filter((fieldName) => !!schema[fieldName] && schema[fieldName].type !== SchemaType.Geolocation) + .filter((fieldName) => !!schema[fieldName].capabilities.facet) + .reduce((facetsConfig, fieldName) => { + return { + ...facetsConfig, + [fieldName]: { type: 'value', size: 30 }, + }; + }, {}); + + const resultFields = Object.entries(schema) + .filter(([, schemaField]) => schemaField.type !== SchemaType.Nested) + .reduce((acc, [fieldName, schemaField]) => { + if (schemaField.capabilities.snippet) { + return { ...acc, [fieldName]: { raw: {}, snippet: { size: 300 } } }; + } + return { ...acc, [fieldName]: { raw: {} } }; + }, {}); return { alwaysSearchOnInitialLoad: true, @@ -34,25 +42,9 @@ export const buildSearchUIConfig = ( trackUrlState: false, initialState, searchQuery: { - disjunctiveFacets: fields.filterFields, + disjunctiveFacets: Object.keys(facets), facets, - result_fields: Object.keys(schema).reduce((acc: { [key: string]: object }, key: string) => { - if (schema[key] === 'text') { - // Only text fields support snippets - acc[key] = { - snippet: { - size: 300, - fallback: true, - }, - raw: {}, - }; - } else { - acc[key] = { - raw: {}, - }; - } - return acc; - }, {}), + result_fields: resultFields, }, }; }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/customization_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/customization_modal.tsx index 534751465f3026..299a840cf8919b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/customization_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/customization_modal.tsx @@ -52,14 +52,20 @@ export const CustomizationModal: React.FC = ({ sortFields.map(fieldNameToComboBoxOption) ); - const engineSchema = engine.schema || {}; + const schema = engine.advancedSchema || {}; const selectableFilterFields = useMemo( - () => Object.keys(engineSchema).map(fieldNameToComboBoxOption), - [engineSchema] + () => + Object.keys(schema) + .filter((fieldName) => schema[fieldName].capabilities.filter) + .map(fieldNameToComboBoxOption), + [schema] ); const selectableSortFields = useMemo( - () => Object.keys(engineSchema).map(fieldNameToComboBoxOption), - [engineSchema] + () => + Object.keys(schema) + .filter((fieldName) => schema[fieldName].capabilities.sort) + .map(fieldNameToComboBoxOption), + [schema] ); return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx index 397bcd2e14f83d..441841364489c5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx @@ -100,7 +100,7 @@ export const SearchExperience: React.FC = () => { const searchProviderConfig = buildSearchUIConfig( connector, - engine.schema || {}, + engine.advancedSchema || {}, fields, initialState ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts index acd53a89a7f1f4..d82b4f5d4b055d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts @@ -41,6 +41,19 @@ describe('EngineLogic', () => { isMeta: false, invalidBoosts: false, schema: { test: SchemaType.Text }, + advancedSchema: { + test: { + type: SchemaType.Text, + capabilities: { + fulltext: true, + filter: true, + facet: true, + sort: true, + snippet: true, + boost: true, + }, + }, + }, apiTokens: [], apiKey: 'some-key', adaptive_relevance_suggestions_active: true, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts index c9214e3c6b0b6d..1b4aa08980ef51 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts @@ -5,7 +5,12 @@ * 2.0. */ -import { Schema, SchemaConflicts, IIndexingStatus } from '../../../shared/schema/types'; +import { + Schema, + SchemaConflicts, + IIndexingStatus, + AdvancedSchema, +} from '../../../shared/schema/types'; import { ApiToken } from '../credentials/types'; export enum EngineTypes { @@ -47,6 +52,7 @@ export interface EngineDetails extends Engine { apiKey: string; elasticsearchIndexName?: string; schema: Schema; + advancedSchema: AdvancedSchema; schemaConflicts?: SchemaConflicts; unconfirmedFields?: string[]; activeReindexJob?: IIndexingStatus; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_token.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_token.tsx index 353d303da2b5a8..d2579d61419e59 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_token.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_token.tsx @@ -26,6 +26,7 @@ const fieldTypeToTokenMap = { [InternalSchemaType.Location]: 'tokenGeo', [SchemaType.Date]: 'tokenDate', [InternalSchemaType.Date]: 'tokenDate', + [InternalSchemaType.Nested]: 'tokenNested', }; export const ResultToken: React.FC = ({ fieldType }) => { diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/schema/types.ts b/x-pack/plugins/enterprise_search/public/applications/shared/schema/types.ts index 58ad584fd5b60c..6e206ddc719718 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/schema/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/schema/types.ts @@ -14,6 +14,7 @@ export enum SchemaType { Number = 'number', Geolocation = 'geolocation', Date = 'date', + Nested = 'nested', } // Certain API endpoints will use these internal type names, which map to the external names above export enum InternalSchemaType { @@ -21,6 +22,7 @@ export enum InternalSchemaType { Float = 'float', Location = 'location', Date = 'date', + Nested = 'nested', } export type Schema = Record; @@ -62,3 +64,20 @@ export interface FieldCoercionError { error: string; } export type FieldCoercionErrors = Record; + +export interface SchemaFieldCapabilities { + fulltext?: boolean; + filter?: boolean; + facet?: boolean; + sort?: boolean; + snippet?: boolean; + boost?: boolean; +} + +export interface AdvancedSchemaField { + type: SchemaType; + nestedPath?: string; + capabilities: SchemaFieldCapabilities; +} + +export type AdvancedSchema = Record; From 56831590e4808d634ff52a8983ee5ae49547ad4c Mon Sep 17 00:00:00 2001 From: Tomasz Ciecierski Date: Fri, 8 Jul 2022 14:19:01 +0200 Subject: [PATCH 03/47] [Osquery] More fixes to 8.3 (#134311) * fix platform change in packs - saved queries * fix minimum version of osquery in saved queries and fix platform again --- x-pack/plugins/osquery/public/packs/queries/query_flyout.tsx | 2 +- .../osquery/public/packs/queries/use_pack_query_form.tsx | 5 ----- .../public/saved_queries/form/use_saved_query_form.tsx | 2 +- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/osquery/public/packs/queries/query_flyout.tsx b/x-pack/plugins/osquery/public/packs/queries/query_flyout.tsx index 2265420c07a334..5c6d2609c44dfd 100644 --- a/x-pack/plugins/osquery/public/packs/queries/query_flyout.tsx +++ b/x-pack/plugins/osquery/public/packs/queries/query_flyout.tsx @@ -72,7 +72,7 @@ const QueryFlyoutComponent: React.FC = ({ id: savedQuery.id, query: savedQuery.query, description: savedQuery.description, - platform: savedQuery.platform, + platform: savedQuery.platform ? savedQuery.platform : 'linux,windows,darwin', version: savedQuery.version, interval: savedQuery.interval, // @ts-expect-error update types diff --git a/x-pack/plugins/osquery/public/packs/queries/use_pack_query_form.tsx b/x-pack/plugins/osquery/public/packs/queries/use_pack_query_form.tsx index 2b044a443004d4..41a25bce0405f7 100644 --- a/x-pack/plugins/osquery/public/packs/queries/use_pack_query_form.tsx +++ b/x-pack/plugins/osquery/public/packs/queries/use_pack_query_form.tsx @@ -89,11 +89,6 @@ export const usePackQueryForm = ({ draft.platform.join(','); } - if (draft.platform?.split(',').length === 3) { - // if all platforms are checked then use undefined - delete draft.platform; - } - if (isArray(draft.version)) { if (!draft.version.length) { delete draft.version; diff --git a/x-pack/plugins/osquery/public/saved_queries/form/use_saved_query_form.tsx b/x-pack/plugins/osquery/public/saved_queries/form/use_saved_query_form.tsx index 1d0d9f28d097b6..a1350eceff89ce 100644 --- a/x-pack/plugins/osquery/public/saved_queries/form/use_saved_query_form.tsx +++ b/x-pack/plugins/osquery/public/saved_queries/form/use_saved_query_form.tsx @@ -59,7 +59,7 @@ export const useSavedQueryForm = ({ defaultValue, handleSubmit }: UseSavedQueryF if (isArray(draft.version)) { if (!draft.version.length) { // @ts-expect-error update types - delete draft.version; + draft.version = ''; } else { draft.version = draft.version[0]; } From b3c568922444817a31b051d6548306e66f887588 Mon Sep 17 00:00:00 2001 From: Tomasz Ciecierski Date: Fri, 8 Jul 2022 14:19:23 +0200 Subject: [PATCH 04/47] [Osquery] Add additional ecs osquery fields validation on query change (#134431) --- x-pack/plugins/osquery/public/common/helpers.ts | 10 ++++++++++ .../packs/queries/ecs_mapping_editor_field.tsx | 15 +++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/x-pack/plugins/osquery/public/common/helpers.ts b/x-pack/plugins/osquery/public/common/helpers.ts index 5f83330fe9ee1b..a4683b60bcfa78 100644 --- a/x-pack/plugins/osquery/public/common/helpers.ts +++ b/x-pack/plugins/osquery/public/common/helpers.ts @@ -15,6 +15,7 @@ import { } from '../../common/search_strategy'; import { ESQuery } from '../../common/typed_json'; +import { ArrayItem } from '../shared_imports'; export const createFilter = (filterQuery: ESQuery | string | undefined) => isString(filterQuery) ? filterQuery : JSON.stringify(filterQuery); @@ -43,3 +44,12 @@ export const getInspectResponse = ( response: response != null ? [JSON.stringify(response.rawResponse, null, 2)] : prevResponse?.response, }); + +export const prepareEcsFieldsToValidate = (ecsMapping: ArrayItem[]): string[] => + ecsMapping + ?.map((_: unknown, index: number) => [ + `ecs_mapping[${index}].result.value`, + `ecs_mapping[${index}].key`, + ]) + .join(',') + .split(','); diff --git a/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx b/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx index 55c3b545aedb5c..989e08f64d2748 100644 --- a/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx +++ b/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx @@ -40,6 +40,7 @@ import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; +import { prepareEcsFieldsToValidate } from '../../common/helpers'; import ECSSchema from '../../common/schemas/ecs/v8.2.0.json'; import osquerySchema from '../../common/schemas/osquery/v5.2.2.json'; @@ -57,6 +58,7 @@ import { UseArray, ArrayItem, FormArrayField, + useFormContext, } from '../../shared_imports'; import { OsqueryIcon } from '../../components/osquery_icon'; import { removeMultilines } from '../../../common/utils/build_query/remove_multilines'; @@ -768,9 +770,21 @@ export const ECSMappingEditorField = React.memo( ({ euiFieldProps }: ECSMappingEditorFieldProps) => { const lastItemPath = useRef(); const onAdd = useRef(); + const itemsList = useRef([]); const [osquerySchemaOptions, setOsquerySchemaOptions] = useState([]); const [{ query, ...formData }, formDataSerializer, isMounted] = useFormData(); + const { validateFields } = useFormContext(); + + useEffect(() => { + // Additional 'suspended' validation of osquery ecs fields. fieldsToValidateOnChange doesn't work because it happens before the osquerySchema gets updated. + const fieldsToValidate = prepareEcsFieldsToValidate(itemsList.current); + // it is always at least 2 - empty fields + if (fieldsToValidate.length > 2) { + setTimeout(() => validateFields(fieldsToValidate), 0); + } + }, [query, validateFields]); + useEffect(() => { if (!query?.length) { return; @@ -1074,6 +1088,7 @@ export const ECSMappingEditorField = React.memo( {({ items, addItem, removeItem }) => { lastItemPath.current = items[items.length - 1]?.path; onAdd.current = addItem; + itemsList.current = items; return ( <> From 943c3665cff70db7a9df7c4dd7462c0ff8423bea Mon Sep 17 00:00:00 2001 From: Julia Bardi <90178898+juliaElastic@users.noreply.github.com> Date: Fri, 8 Jul 2022 14:30:03 +0200 Subject: [PATCH 05/47] [Fleet] using point in time for agent status query to avoid discrepancy (#135816) * using point in time for agent status query to avoid discrepancy * catching error when index does not exist yet * disable Add / remove tags action for inactive agents * included inactive status in api tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/table_row_actions.tsx | 1 + .../fleet/server/services/agents/crud.ts | 121 ++++-------------- .../fleet/server/services/agents/status.ts | 31 ++++- .../apis/agents/status.ts | 22 +++- 4 files changed, 74 insertions(+), 101 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/table_row_actions.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/table_row_actions.tsx index cb9001faf11d21..485bf31ca77a83 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/table_row_actions.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/table_row_actions.tsx @@ -54,6 +54,7 @@ export const TableRowActions: React.FunctionComponent<{ onClick={(event) => { onAddRemoveTagsClick((event.target as Element).closest('button')!); }} + disabled={!agent.active} key="addRemoveTags" > { - const { - page = 1, - perPage = 20, - sortField = 'enrolled_at', - sortOrder = 'desc', - kuery, - showInactive = false, - showUpgradeable, - searchAfter, - } = options; - const { pitId } = options; - const filters = []; - - if (kuery && kuery !== '') { - filters.push(kuery); - } - - if (showInactive === false) { - filters.push(ACTIVE_AGENT_CONDITION); - } - - const kueryNode = _joinFilters(filters); - const body = kueryNode ? { query: toElasticsearchQuery(kueryNode) } : {}; - - const queryAgents = async (from: number, size: number) => { - return esClient.search({ - from, - size, - track_total_hits: true, - rest_total_hits_as_int: true, - body: { - ...body, - sort: [{ [sortField]: { order: sortOrder } }], - }, - pit: { - id: pitId, - keep_alive: '1m', - }, - ...(searchAfter ? { search_after: searchAfter, from: 0 } : {}), - }); - }; - - const res = await queryAgents((page - 1) * perPage, perPage); - - let agents = res.hits.hits.map(searchHitToAgent); - let total = res.hits.total as number; - - // filtering for a range on the version string will not work, - // nor does filtering on a flattened field (local_metadata), so filter here - if (showUpgradeable) { - // query all agents, then filter upgradeable, and return the requested page and correct total - // if there are more than SO_SEARCH_LIMIT agents, the logic falls back to same as before - if (total < SO_SEARCH_LIMIT) { - const response = await queryAgents(0, SO_SEARCH_LIMIT); - agents = response.hits.hits - .map(searchHitToAgent) - .filter((agent) => isAgentUpgradeable(agent, appContextService.getKibanaVersion())); - total = agents.length; - const start = (page - 1) * perPage; - agents = agents.slice(start, start + perPage); - } else { - agents = agents.filter((agent) => - isAgentUpgradeable(agent, appContextService.getKibanaVersion()) - ); - } - } - - return { - agents, - total, - page, - perPage, - }; -} - -export async function openAgentsPointInTime(esClient: ElasticsearchClient): Promise { + index: string = AGENTS_INDEX +): Promise { const pitKeepAlive = '10m'; const pitRes = await esClient.openPointInTime({ - index: AGENTS_INDEX, + index, keep_alive: pitKeepAlive, }); return pitRes.id; } -export async function closeAgentsPointInTime(esClient: ElasticsearchClient, pitId: string) { +export async function closePointInTime(esClient: ElasticsearchClient, pitId: string) { try { await esClient.closePointInTime({ id: pitId }); } catch (error) { @@ -205,6 +120,8 @@ export async function getAgentsByKuery( showInactive: boolean; sortField?: string; sortOrder?: 'asc' | 'desc'; + pitId?: string; + searchAfter?: SortResults; } ): Promise<{ agents: Agent[]; @@ -220,6 +137,8 @@ export async function getAgentsByKuery( kuery, showInactive = false, showUpgradeable, + searchAfter, + pitId, } = options; const filters = []; @@ -240,16 +159,26 @@ export async function getAgentsByKuery( : []; const queryAgents = async (from: number, size: number) => esClient.search({ - index: AGENTS_INDEX, from, size, track_total_hits: true, rest_total_hits_as_int: true, - ignore_unavailable: true, body: { ...body, sort: [{ [sortField]: { order: sortOrder } }, ...secondarySort], }, + ...(pitId + ? { + pit: { + id: pitId, + keep_alive: '1m', + }, + } + : { + index: AGENTS_INDEX, + ignore_unavailable: true, + }), + ...(pitId && searchAfter ? { search_after: searchAfter, from: 0 } : {}), }); const res = await queryAgents((page - 1) * perPage, perPage); @@ -295,11 +224,11 @@ export async function processAgentsInBatches( includeSuccess: boolean ) => Promise<{ items: BulkActionResult[] }> ): Promise<{ items: BulkActionResult[] }> { - const pitId = await openAgentsPointInTime(esClient); + const pitId = await openPointInTime(esClient); const perPage = options.batchSize ?? SO_SEARCH_LIMIT; - const res = await getAgentsByKueryPit(esClient, { + const res = await getAgentsByKuery(esClient, { ...options, page: 1, perPage, @@ -315,7 +244,7 @@ export async function processAgentsInBatches( while (allAgentsProcessed < res.total) { const lastAgent = currentAgents[currentAgents.length - 1]; - const nextPage = await getAgentsByKueryPit(esClient, { + const nextPage = await getAgentsByKuery(esClient, { ...options, page: 1, perPage, @@ -328,7 +257,7 @@ export async function processAgentsInBatches( allAgentsProcessed += currentAgents.length; } - await closeAgentsPointInTime(esClient, pitId); + await closePointInTime(esClient, pitId); return results; } diff --git a/x-pack/plugins/fleet/server/services/agents/status.ts b/x-pack/plugins/fleet/server/services/agents/status.ts index c86e6e1df274fc..444108ec24ad5d 100644 --- a/x-pack/plugins/fleet/server/services/agents/status.ts +++ b/x-pack/plugins/fleet/server/services/agents/status.ts @@ -16,7 +16,15 @@ import type { AgentStatus } from '../../types'; import { AgentStatusKueryHelper } from '../../../common/services'; import { FleetUnauthorizedError } from '../../errors'; -import { getAgentById, getAgentsByKuery, removeSOAttributes } from './crud'; +import { appContextService } from '../app_context'; + +import { + closePointInTime, + getAgentById, + getAgentsByKuery, + openPointInTime, + removeSOAttributes, +} from './crud'; const DATA_STREAM_INDEX_PATTERN = 'logs-*-*,metrics-*-*,traces-*-*,synthetics-*-*'; const MAX_AGENT_DATA_PREVIEW_SIZE = 20; @@ -55,6 +63,18 @@ export async function getAgentStatusForAgentPolicy( agentPolicyId?: string, filterKuery?: string ) { + let pitId: string | undefined; + try { + pitId = await openPointInTime(esClient); + } catch (error) { + if (error.statusCode === 404) { + appContextService + .getLogger() + .debug('Index .fleet-agents does not exist yet, skipping point in time.'); + } else { + throw error; + } + } const [all, allActive, online, error, offline, updating] = await pMap( [ undefined, // All agents, including inactive @@ -69,11 +89,11 @@ export async function getAgentStatusForAgentPolicy( showInactive: index === 0, perPage: 0, page: 1, + pitId, kuery: joinKuerys( ...[ kuery, filterKuery, - `${AGENTS_PREFIX}.attributes.active:true`, agentPolicyId ? `${AGENTS_PREFIX}.policy_id:"${agentPolicyId}"` : undefined, ] ), @@ -83,7 +103,11 @@ export async function getAgentStatusForAgentPolicy( } ); - return { + if (pitId) { + await closePointInTime(esClient, pitId); + } + + const result = { total: allActive.total, inactive: all.total - allActive.total, online: online.total, @@ -94,6 +118,7 @@ export async function getAgentStatusForAgentPolicy( /* @deprecated Agent events do not exists anymore */ events: 0, }; + return result; } export async function getIncomingDataByAgentsId( esClient: ElasticsearchClient, diff --git a/x-pack/test/fleet_api_integration/apis/agents/status.ts b/x-pack/test/fleet_api_integration/apis/agents/status.ts index 9fc257f10c81df..0d22eeeae6228f 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/status.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/status.ts @@ -62,6 +62,24 @@ export default function ({ getService }: FtrProviderContext) { }, }, }); + // 1 agent inactive + await es.create({ + id: 'agent5', + refresh: 'wait_for', + index: AGENTS_INDEX, + body: { + doc: { + active: false, + access_api_key_id: 'api-key-4', + policy_id: 'policy1', + type: 'PERMANENT', + local_metadata: { host: { hostname: 'host5' } }, + user_provided_metadata: {}, + enrolled_at: '2022-06-21T12:17:25Z', + last_checkin: '2022-06-27T12:29:29Z', + }, + }, + }); }); after(async () => { await esArchiver.unload('x-pack/test/functional/es_archives/fleet/agents'); @@ -78,8 +96,8 @@ export default function ({ getService }: FtrProviderContext) { error: 0, offline: 1, updating: 1, - other: 1, - inactive: 0, + other: 2, + inactive: 1, }, }); }); From 71dab14ea5d7deac55cb752e84870dace158e057 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Fri, 8 Jul 2022 08:37:05 -0400 Subject: [PATCH 06/47] [Fleet] Invalidate api keys in agents default_api_key_history on force unenroll (#135910) --- .../fleet/common/types/models/agent.ts | 8 ++++ .../server/services/agents/unenroll.test.ts | 38 ++++++++++++++++++- .../fleet/server/services/agents/unenroll.ts | 8 ++-- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/fleet/common/types/models/agent.ts b/x-pack/plugins/fleet/common/types/models/agent.ts index 65c719947e52a9..c8b175ffb9dc10 100644 --- a/x-pack/plugins/fleet/common/types/models/agent.ts +++ b/x-pack/plugins/fleet/common/types/models/agent.ts @@ -85,6 +85,7 @@ interface AgentBase { export interface Agent extends AgentBase { id: string; access_api_key?: string; + default_api_key_history?: FleetServerAgent['default_api_key_history']; status?: AgentStatus; packages: string[]; sort?: Array; @@ -206,6 +207,13 @@ export interface FleetServerAgent { * A list of tags used for organizing/filtering agents */ tags?: string[]; + /** + * Default API Key History + */ + default_api_key_history?: Array<{ + id: string; + retired_at: string; + }>; } /** * An Elastic Agent metadata diff --git a/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts b/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts index 45f40916598a15..9ad39990b9ffa0 100644 --- a/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts @@ -10,8 +10,13 @@ import type { SavedObject } from '@kbn/core/server'; import type { AgentPolicy } from '../../types'; import { HostedAgentPolicyRestrictionRelatedError } from '../../errors'; +import { invalidateAPIKeys } from '../api_keys'; -import { unenrollAgent, unenrollAgents } from './unenroll'; +import { invalidateAPIKeysForAgents, unenrollAgent, unenrollAgents } from './unenroll'; + +jest.mock('../api_keys'); + +const mockedInvalidateAPIKeys = invalidateAPIKeys as jest.MockedFunction; const agentInHostedDoc = { _id: 'agent-in-hosted-policy', @@ -229,6 +234,37 @@ describe('unenrollAgents (plural)', () => { }); }); +describe('invalidateAPIKeysForAgents', () => { + beforeEach(() => { + mockedInvalidateAPIKeys.mockReset(); + }); + it('revoke all the agents API keys', async () => { + await invalidateAPIKeysForAgents([ + { + id: 'agent1', + default_api_key_id: 'defaultApiKey1', + access_api_key_id: 'accessApiKey1', + default_api_key_history: [ + { + id: 'defaultApiKeyHistory1', + }, + { + id: 'defaultApiKeyHistory2', + }, + ], + } as any, + ]); + + expect(mockedInvalidateAPIKeys).toBeCalledTimes(1); + expect(mockedInvalidateAPIKeys).toBeCalledWith([ + 'accessApiKey1', + 'defaultApiKey1', + 'defaultApiKeyHistory1', + 'defaultApiKeyHistory2', + ]); + }); +}); + function createClientMock() { const soClientMock = savedObjectsClientMock.create(); diff --git a/x-pack/plugins/fleet/server/services/agents/unenroll.ts b/x-pack/plugins/fleet/server/services/agents/unenroll.ts index 7d00f34377ed18..d8a5b4b32a101b 100644 --- a/x-pack/plugins/fleet/server/services/agents/unenroll.ts +++ b/x-pack/plugins/fleet/server/services/agents/unenroll.ts @@ -8,7 +8,7 @@ import type { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server'; import type { Agent, BulkActionResult } from '../../types'; -import * as APIKeyService from '../api_keys'; +import { invalidateAPIKeys } from '../api_keys'; import { HostedAgentPolicyRestrictionRelatedError } from '../../errors'; import { createAgentAction } from './actions'; @@ -163,12 +163,14 @@ export async function invalidateAPIKeysForAgents(agents: Agent[]) { if (agent.default_api_key_id) { keys.push(agent.default_api_key_id); } - + if (agent.default_api_key_history) { + agent.default_api_key_history.forEach((apiKey) => keys.push(apiKey.id)); + } return keys; }, []); if (apiKeys.length) { - await APIKeyService.invalidateAPIKeys(apiKeys); + await invalidateAPIKeys(apiKeys); } } From 4a23325213a15cf1fef2f9bd5107a8d0ba5fc552 Mon Sep 17 00:00:00 2001 From: Marc Guasch Date: Fri, 8 Jul 2022 15:08:03 +0200 Subject: [PATCH 07/47] [fleet] Add escape_string and to_json helpers (#135992) * Add escape_string and to_json helpers * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * Add regexp for new line and fix tests * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../server/services/epm/agent/agent.test.ts | 89 +++++++++++++++++++ .../fleet/server/services/epm/agent/agent.ts | 19 ++++ 2 files changed, 108 insertions(+) diff --git a/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts b/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts index df4800b241abb3..9163b39575f876 100644 --- a/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts @@ -199,6 +199,95 @@ pcap: false }); }); + describe('escape_string helper', () => { + const streamTemplate = ` +input: log +password: {{escape_string password}} + `; + + const streamTemplateWithNewlinesAndEscapes = ` +input: log +text_var: {{escape_string text_var}} + `; + + it('should wrap in single quotes and escape any single quotes in the string', () => { + const vars = { + password: { type: 'password', value: "ab'c'" }, + }; + + const output = compileTemplate(vars, streamTemplate); + expect(output).toEqual({ + input: 'log', + password: "ab'c'", + }); + }); + + it('should respect new lines and literal escapes', () => { + const vars = { + text_var: { + type: 'text', + value: `This is a text with +New lines and \\n escaped values.`, + }, + }; + + const output = compileTemplate(vars, streamTemplateWithNewlinesAndEscapes); + expect(output).toEqual({ + input: 'log', + text_var: `This is a text with +New lines and \\n escaped values.`, + }); + }); + }); + + describe('to_json helper', () => { + const streamTemplate = ` +input: log +json_var: {{to_json json_var}} + `; + + const streamTemplateWithNewYaml = ` +input: log +yaml_var: {{to_json yaml_var}} + `; + + it('should parse a json string into a json object', () => { + const vars = { + json_var: { type: 'text', value: `{"foo":["bar","bazz"]}` }, + }; + + const output = compileTemplate(vars, streamTemplate); + expect(output).toEqual({ + input: 'log', + json_var: { + foo: ['bar', 'bazz'], + }, + }); + }); + + it('should parse a yaml string into a json object', () => { + const vars = { + yaml_var: { + type: 'yaml', + value: `foo: + bar: + - a + - b`, + }, + }; + + const output = compileTemplate(vars, streamTemplateWithNewYaml); + expect(output).toEqual({ + input: 'log', + yaml_var: { + foo: { + bar: ['a', 'b'], + }, + }, + }); + }); + }); + it('should support optional yaml values at root level', () => { const streamTemplate = ` input: logs diff --git a/x-pack/plugins/fleet/server/services/epm/agent/agent.ts b/x-pack/plugins/fleet/server/services/epm/agent/agent.ts index 762bc1ea624e17..dd8f263b255ee1 100644 --- a/x-pack/plugins/fleet/server/services/epm/agent/agent.ts +++ b/x-pack/plugins/fleet/server/services/epm/agent/agent.ts @@ -110,6 +110,25 @@ function containsHelper(this: any, item: string, check: string | string[], optio } handlebars.registerHelper('contains', containsHelper); +// escapeStringHelper will wrap the provided string with single quotes. +// Single quoted strings in yaml need to escape single quotes by doubling them +// and to respect any incoming newline we also need to double them, otherwise +// they will be replaced with a space. +function escapeStringHelper(str: string) { + return "'" + str.replace(/\'/g, "''").replace(/\n/g, '\n\n') + "'"; +} +handlebars.registerHelper('escape_string', escapeStringHelper); + +// toJsonHelper will convert any object to a Json string. +function toJsonHelper(value: any) { + if (typeof value === 'string') { + // if we get a string we assume is an already serialized json + return value; + } + return JSON.stringify(value); +} +handlebars.registerHelper('to_json', toJsonHelper); + function replaceRootLevelYamlVariables(yamlVariables: { [k: string]: any }, yamlTemplate: string) { if (Object.keys(yamlVariables).length === 0 || !yamlTemplate) { return yamlTemplate; From 7731c414dbef3a22f048cdb7ff7fb1ed06b949fe Mon Sep 17 00:00:00 2001 From: spalger Date: Fri, 8 Jul 2022 08:43:51 -0500 Subject: [PATCH 08/47] [ci] build webpack bundle repotrs on a larger machine We've seen OOMs on this recently, to prevent blocking more people we're just going to bump the instance size until we can narrow down the problem. --- .buildkite/pipelines/pull_request/base.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.buildkite/pipelines/pull_request/base.yml b/.buildkite/pipelines/pull_request/base.yml index 73c03e0382a0fd..eca8e8280d8e88 100644 --- a/.buildkite/pipelines/pull_request/base.yml +++ b/.buildkite/pipelines/pull_request/base.yml @@ -79,6 +79,6 @@ steps: - command: .buildkite/scripts/steps/webpack_bundle_analyzer/build_and_upload.sh label: 'Build Webpack Bundle Analyzer reports' agents: - queue: c2-4 + queue: c2-16 key: webpack_bundle_analyzer timeout_in_minutes: 60 From 1134d35e0341baed53e1ffbcfe01f2404b8f638e Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 8 Jul 2022 08:54:38 -0500 Subject: [PATCH 09/47] [jest] refactor config check (#135960) --- package.json | 3 + .../configs/find_missing_config_files.test.ts | 84 ----------- .../jest/configs/find_missing_config_files.ts | 49 ------ .../src/jest/configs/get_all_jest_paths.ts | 74 +++++++++ .../src/jest/configs/get_all_test_files.ts | 32 ---- .../configs/get_tests_for_config_paths.ts | 52 +++++++ .../src/jest/configs/group_test_files.test.ts | 117 --------------- .../src/jest/configs/group_test_files.ts | 95 ------------ packages/kbn-test/src/jest/configs/index.ts | 9 +- .../src/jest/run_check_jest_configs_cli.ts | 141 ++++++++---------- src/core/jest.config.js | 14 -- src/plugins/chart_expressions/jest.config.js | 13 -- src/plugins/vis_types/jest.config.js | 13 -- test/analytics/jest.config.js | 13 -- yarn.lock | 6 +- 15 files changed, 195 insertions(+), 520 deletions(-) delete mode 100644 packages/kbn-test/src/jest/configs/find_missing_config_files.test.ts delete mode 100644 packages/kbn-test/src/jest/configs/find_missing_config_files.ts create mode 100644 packages/kbn-test/src/jest/configs/get_all_jest_paths.ts delete mode 100644 packages/kbn-test/src/jest/configs/get_all_test_files.ts create mode 100644 packages/kbn-test/src/jest/configs/get_tests_for_config_paths.ts delete mode 100644 packages/kbn-test/src/jest/configs/group_test_files.test.ts delete mode 100644 packages/kbn-test/src/jest/configs/group_test_files.ts delete mode 100644 src/core/jest.config.js delete mode 100644 src/plugins/chart_expressions/jest.config.js delete mode 100644 src/plugins/vis_types/jest.config.js delete mode 100644 test/analytics/jest.config.js diff --git a/package.json b/package.json index 4764f833786cba..f6d01e45fcb1aa 100644 --- a/package.json +++ b/package.json @@ -532,6 +532,7 @@ "@istanbuljs/schema": "^0.1.2", "@jest/console": "^26.6.2", "@jest/reporters": "^26.6.2", + "@jest/types": "^26", "@kbn/ambient-storybook-types": "link:bazel-bin/packages/kbn-ambient-storybook-types", "@kbn/ambient-ui-types": "link:bazel-bin/packages/kbn-ambient-ui-types", "@kbn/axe-config": "link:bazel-bin/packages/kbn-axe-config", @@ -1026,10 +1027,12 @@ "jest-canvas-mock": "^2.3.1", "jest-circus": "^26.6.3", "jest-cli": "^26.6.3", + "jest-config": "^26", "jest-diff": "^26.6.2", "jest-environment-jsdom": "^26.6.2", "jest-environment-jsdom-thirteen": "^1.0.1", "jest-raw-loader": "^1.0.1", + "jest-runtime": "^26", "jest-silent-reporter": "^0.5.0", "jest-snapshot": "^26.6.2", "jest-specific-snapshot": "2.0.0", diff --git a/packages/kbn-test/src/jest/configs/find_missing_config_files.test.ts b/packages/kbn-test/src/jest/configs/find_missing_config_files.test.ts deleted file mode 100644 index 93365157692f5e..00000000000000 --- a/packages/kbn-test/src/jest/configs/find_missing_config_files.test.ts +++ /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 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 mockFs from 'mock-fs'; - -import { GroupedTestFiles } from './group_test_files'; -import { - findMissingConfigFiles, - INTEGRATION_CONFIG_NAME, - UNIT_CONFIG_NAME, -} from './find_missing_config_files'; - -beforeEach(async () => { - mockFs({ - '/packages': { - a: { - [UNIT_CONFIG_NAME]: '{}', - }, - }, - '/src': { - c: { - [UNIT_CONFIG_NAME]: '{}', - }, - d: { - [INTEGRATION_CONFIG_NAME]: '{}', - }, - }, - }); -}); - -afterEach(mockFs.restore); - -it('returns a list of config files which are not found on disk, or are not files', async () => { - const groups: GroupedTestFiles = new Map([ - [ - { - type: 'pkg', - path: '/packages/a', - }, - { - unit: ['/packages/a/test.js'], - }, - ], - [ - { - type: 'pkg', - path: '/packages/b', - }, - { - integration: ['/packages/b/integration_tests/test.js'], - }, - ], - [ - { - type: 'src', - path: '/src/c', - }, - { - unit: ['/src/c/test.js'], - integration: ['/src/c/integration_tests/test.js'], - }, - ], - [ - { - type: 'src', - path: '/src/d', - }, - { - unit: ['/src/d/test.js'], - }, - ], - ]); - - await expect(findMissingConfigFiles(groups)).resolves.toEqual([ - '/packages/b/jest.integration.config.js', - '/src/c/jest.integration.config.js', - '/src/d/jest.config.js', - ]); -}); diff --git a/packages/kbn-test/src/jest/configs/find_missing_config_files.ts b/packages/kbn-test/src/jest/configs/find_missing_config_files.ts deleted file mode 100644 index b612643360651d..00000000000000 --- a/packages/kbn-test/src/jest/configs/find_missing_config_files.ts +++ /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 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 Fsp from 'fs/promises'; -import Path from 'path'; - -import { asyncMapWithLimit } from '@kbn/std'; - -import { GroupedTestFiles } from './group_test_files'; - -export const UNIT_CONFIG_NAME = 'jest.config.js'; -export const INTEGRATION_CONFIG_NAME = 'jest.integration.config.js'; - -async function isFile(path: string) { - try { - const stats = await Fsp.stat(path); - return stats.isFile(); - } catch (error) { - if (error.code === 'ENOENT') { - return false; - } - - throw error; - } -} - -export async function findMissingConfigFiles(groups: GroupedTestFiles) { - const expectedConfigs = [...groups].flatMap(([owner, tests]) => { - const configs: string[] = []; - if (tests.unit?.length) { - configs.push(Path.resolve(owner.path, UNIT_CONFIG_NAME)); - } - if (tests.integration?.length) { - configs.push(Path.resolve(owner.path, INTEGRATION_CONFIG_NAME)); - } - return configs; - }); - - return ( - await asyncMapWithLimit(expectedConfigs, 20, async (path) => - !(await isFile(path)) ? [path] : [] - ) - ).flat(); -} diff --git a/packages/kbn-test/src/jest/configs/get_all_jest_paths.ts b/packages/kbn-test/src/jest/configs/get_all_jest_paths.ts new file mode 100644 index 00000000000000..63a829225ca609 --- /dev/null +++ b/packages/kbn-test/src/jest/configs/get_all_jest_paths.ts @@ -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 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 Path from 'path'; + +import execa from 'execa'; +import minimatch from 'minimatch'; +import { REPO_ROOT } from '@kbn/utils'; + +// @ts-expect-error jest-preset is necessarily a JS file +import { testMatch } from '../../../jest-preset'; + +const UNIT_CONFIG_NAME = 'jest.config.js'; +const INTEGRATION_CONFIG_NAME = 'jest.integration.config.js'; + +export async function getAllJestPaths() { + const proc = await execa('git', ['ls-files', '-comt', '--exclude-standard'], { + cwd: REPO_ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + buffer: true, + }); + + const testsRe = (testMatch as string[]).map((p) => minimatch.makeRe(p)); + const classify = (rel: string) => { + if (testsRe.some((re) => re.test(rel))) { + return 'test' as const; + } + + const basename = Path.basename(rel); + return basename === UNIT_CONFIG_NAME || basename === INTEGRATION_CONFIG_NAME + ? ('config' as const) + : undefined; + }; + + const tests = new Set(); + const configs = new Set(); + + for (const line of proc.stdout.split('\n').map((l) => l.trim())) { + if (!line) { + continue; + } + + const rel = line.slice(2); // trim the single char status from the line + const type = classify(rel); + + if (!type) { + continue; + } + + const set = type === 'test' ? tests : configs; + const abs = Path.resolve(REPO_ROOT, rel); + + if (line.startsWith('C ')) { + // this line indicates that the previous path is changed in the working tree, so we need to determine if + // it was deleted, and if so, remove it from the set we added it to + if (!Fs.existsSync(abs)) { + set.delete(abs); + } + } else { + set.add(abs); + } + } + + return { + tests, + configs, + }; +} diff --git a/packages/kbn-test/src/jest/configs/get_all_test_files.ts b/packages/kbn-test/src/jest/configs/get_all_test_files.ts deleted file mode 100644 index 695ec07eaf2dc8..00000000000000 --- a/packages/kbn-test/src/jest/configs/get_all_test_files.ts +++ /dev/null @@ -1,32 +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 Path from 'path'; - -import execa from 'execa'; -import minimatch from 'minimatch'; -import { REPO_ROOT } from '@kbn/utils'; - -// @ts-expect-error jest-preset is necessarily a JS file -import { testMatch } from '../../../jest-preset'; - -export async function getAllTestFiles() { - const proc = await execa('git', ['ls-files', '-co', '--exclude-standard'], { - cwd: REPO_ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - buffer: true, - }); - - const patterns: RegExp[] = testMatch.map((p: string) => minimatch.makeRe(p)); - - return proc.stdout - .split('\n') - .flatMap((l) => l.trim() || []) - .filter((l) => patterns.some((p) => p.test(l))) - .map((p) => Path.resolve(REPO_ROOT, p)); -} diff --git a/packages/kbn-test/src/jest/configs/get_tests_for_config_paths.ts b/packages/kbn-test/src/jest/configs/get_tests_for_config_paths.ts new file mode 100644 index 00000000000000..9d0105977a12e6 --- /dev/null +++ b/packages/kbn-test/src/jest/configs/get_tests_for_config_paths.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { readConfig } from 'jest-config'; +import { createContext } from 'jest-runtime'; +import { SearchSource } from 'jest'; +import { asyncMapWithLimit } from '@kbn/std'; + +const EMPTY_ARGV = { + $0: '', + _: [], +}; + +const NO_WARNINGS_CONSOLE = { + ...console, + warn() { + // ignore haste-map warnings + }, +}; + +export interface TestsForConfigPath { + path: string; + testPaths: Set; +} + +export async function getTestsForConfigPaths( + configPaths: Iterable +): Promise { + return await asyncMapWithLimit(configPaths, 60, async (path) => { + const config = await readConfig(EMPTY_ARGV, path); + const searchSource = new SearchSource( + await createContext(config.projectConfig, { + maxWorkers: 1, + watchman: false, + watch: false, + console: NO_WARNINGS_CONSOLE, + }) + ); + + const results = await searchSource.getTestPaths(config.globalConfig, undefined, undefined); + + return { + path, + testPaths: new Set(results.tests.map((t) => t.path)), + }; + }); +} diff --git a/packages/kbn-test/src/jest/configs/group_test_files.test.ts b/packages/kbn-test/src/jest/configs/group_test_files.test.ts deleted file mode 100644 index 640100d86f4024..00000000000000 --- a/packages/kbn-test/src/jest/configs/group_test_files.test.ts +++ /dev/null @@ -1,117 +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 { groupTestFiles } from './group_test_files'; - -it('properly assigns tests to src roots and packages based on location', () => { - const grouped = groupTestFiles( - [ - '/packages/pkg1/test.js', - '/packages/pkg1/integration_tests/test.js', - '/packages/pkg2/integration_tests/test.js', - '/packages/group/pkg3/test.js', - '/packages/group/subgroup/pkg4/test.js', - '/packages/group/subgroup/pkg4/integration_tests/test.js', - '/src/a/integration_tests/test.js', - '/src/b/test.js', - '/tests/b/test.js', - '/src/group/c/test.js', - '/src/group/c/integration_tests/test.js', - '/src/group/subgroup/d/test.js', - '/src/group/subgroup/d/integration_tests/test.js', - ], - ['/src/group/subgroup', '/src/group', '/src'], - ['/packages/pkg1', '/packages/pkg2', '/packages/group/pkg3', '/packages/group/subgroup/pkg4'] - ); - - expect(grouped).toMatchInlineSnapshot(` - Object { - "grouped": Map { - Object { - "path": "/packages/pkg1", - "type": "pkg", - } => Object { - "integration": Array [ - "/packages/pkg1/integration_tests/test.js", - ], - "unit": Array [ - "/packages/pkg1/test.js", - ], - }, - Object { - "path": "/packages/pkg2", - "type": "pkg", - } => Object { - "integration": Array [ - "/packages/pkg2/integration_tests/test.js", - ], - }, - Object { - "path": "/packages/group/pkg3", - "type": "pkg", - } => Object { - "unit": Array [ - "/packages/group/pkg3/test.js", - ], - }, - Object { - "path": "/packages/group/subgroup/pkg4", - "type": "pkg", - } => Object { - "integration": Array [ - "/packages/group/subgroup/pkg4/integration_tests/test.js", - ], - "unit": Array [ - "/packages/group/subgroup/pkg4/test.js", - ], - }, - Object { - "path": "/src/a", - "type": "src", - } => Object { - "integration": Array [ - "/src/a/integration_tests/test.js", - ], - }, - Object { - "path": "/src/b", - "type": "src", - } => Object { - "unit": Array [ - "/src/b/test.js", - ], - }, - Object { - "path": "/src/group/c", - "type": "src", - } => Object { - "integration": Array [ - "/src/group/c/integration_tests/test.js", - ], - "unit": Array [ - "/src/group/c/test.js", - ], - }, - Object { - "path": "/src/group/subgroup/d", - "type": "src", - } => Object { - "integration": Array [ - "/src/group/subgroup/d/integration_tests/test.js", - ], - "unit": Array [ - "/src/group/subgroup/d/test.js", - ], - }, - }, - "invalid": Array [ - "/tests/b/test.js", - ], - } - `); -}); diff --git a/packages/kbn-test/src/jest/configs/group_test_files.ts b/packages/kbn-test/src/jest/configs/group_test_files.ts deleted file mode 100644 index 90d68e1f125ee7..00000000000000 --- a/packages/kbn-test/src/jest/configs/group_test_files.ts +++ /dev/null @@ -1,95 +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 Path from 'path'; -import isPathInside from 'is-path-inside'; - -export interface Owner { - type: 'pkg' | 'src'; - path: string; -} -export interface TestGroups { - unit?: string[]; - integration?: string[]; -} -export type GroupedTestFiles = Map; - -/** - * Consumes the list of test files discovered along with the srcRoots and packageDirs to assign - * each test file to a specific "owner", either a package or src directory, were we will eventually - * expect to find relevant config files - */ -export function groupTestFiles( - testFiles: string[], - srcRoots: string[], - packageDirs: string[] -): { grouped: GroupedTestFiles; invalid: string[] } { - const invalid: string[] = []; - const testsByOwner = new Map(); - - for (const testFile of testFiles) { - const type = testFile.includes('integration_tests') ? 'integration' : 'unit'; - let ownerKey; - // try to match the test file to a package first - for (const pkgDir of packageDirs) { - if (isPathInside(testFile, pkgDir)) { - ownerKey = `pkg:${pkgDir}`; - break; - } - } - - // try to match the test file to a src root - if (!ownerKey) { - for (const srcRoot of srcRoots) { - if (isPathInside(testFile, srcRoot)) { - const segments = Path.relative(srcRoot, testFile).split(Path.sep); - if (segments.length > 1) { - ownerKey = `src:${Path.join(srcRoot, segments[0])}`; - break; - } - - // if there are <= 1 relative segments then this file is directly in the "root" - // which isn't supported, roots are directories which have test dirs in them. - // We should ignore this match and match a higher-level root if possible - continue; - } - } - } - - if (!ownerKey) { - invalid.push(testFile); - continue; - } - - const tests = testsByOwner.get(ownerKey); - if (!tests) { - testsByOwner.set(ownerKey, { [type]: [testFile] }); - } else { - const byType = tests[type]; - if (!byType) { - tests[type] = [testFile]; - } else { - byType.push(testFile); - } - } - } - - return { - invalid, - grouped: new Map( - [...testsByOwner.entries()].map(([key, tests]) => { - const [type, ...path] = key.split(':'); - const owner: Owner = { - type: type as Owner['type'], - path: path.join(':'), - }; - return [owner, tests]; - }) - ), - }; -} diff --git a/packages/kbn-test/src/jest/configs/index.ts b/packages/kbn-test/src/jest/configs/index.ts index b66cb8d89cad97..9cb9ffc5877ec7 100644 --- a/packages/kbn-test/src/jest/configs/index.ts +++ b/packages/kbn-test/src/jest/configs/index.ts @@ -6,10 +6,5 @@ * Side Public License, v 1. */ -export { getAllTestFiles } from './get_all_test_files'; -export { groupTestFiles } from './group_test_files'; -export { - findMissingConfigFiles, - UNIT_CONFIG_NAME, - INTEGRATION_CONFIG_NAME, -} from './find_missing_config_files'; +export * from './get_all_jest_paths'; +export * from './get_tests_for_config_paths'; diff --git a/packages/kbn-test/src/jest/run_check_jest_configs_cli.ts b/packages/kbn-test/src/jest/run_check_jest_configs_cli.ts index c2672b02becedb..5adbe0afdbef03 100644 --- a/packages/kbn-test/src/jest/run_check_jest_configs_cli.ts +++ b/packages/kbn-test/src/jest/run_check_jest_configs_cli.ts @@ -6,111 +6,92 @@ * Side Public License, v 1. */ -import Fsp from 'fs/promises'; import Path from 'path'; -import Mustache from 'mustache'; - import { run } from '@kbn/dev-cli-runner'; import { createFailError } from '@kbn/dev-cli-errors'; import { REPO_ROOT } from '@kbn/utils'; -import { discoverBazelPackageLocations } from '@kbn/bazel-packages'; -import { - getAllTestFiles, - groupTestFiles, - findMissingConfigFiles, - UNIT_CONFIG_NAME, -} from './configs'; +import { getAllJestPaths, getTestsForConfigPaths } from './configs'; -const unitTestingTemplate: string = `module.exports = { - preset: '@kbn/test/jest_node', - rootDir: '{{{relToRoot}}}', - roots: ['/{{{modulePath}}}'], -}; -`; +const fmtMs = (ms: number) => { + if (ms < 1000) { + return `${Math.round(ms)} ms`; + } -const integrationTestingTemplate: string = `module.exports = { - preset: '@kbn/test/jest_integration_node', - rootDir: '{{{relToRoot}}}', - roots: ['/{{{modulePath}}}'], + return `${(Math.round(ms) / 1000).toFixed(2)} s`; }; -`; -const roots: string[] = [ - 'x-pack/plugins/security_solution/public', - 'x-pack/plugins/security_solution/server', - 'x-pack/plugins/security_solution', - 'x-pack/plugins', - 'src/plugins', - 'test', - 'src/core', - 'src', -].map((rel) => Path.resolve(REPO_ROOT, rel)); +const fmtList = (list: Iterable) => [...list].map((i) => ` - ${i}`).join('\n'); export async function runCheckJestConfigsCli() { run( - async ({ flags: { fix = false }, log }) => { - const packageDirs = [ - ...discoverBazelPackageLocations(REPO_ROOT), - // kbn-pm is a weird package currently and needs to be added explicitly - Path.resolve(REPO_ROOT, 'packages/kbn-pm'), - ]; + async ({ log }) => { + const start = performance.now(); - const testFiles = await getAllTestFiles(); - const { grouped, invalid } = groupTestFiles(testFiles, roots, packageDirs); + const jestPaths = await getAllJestPaths(); + const allConfigs = await getTestsForConfigPaths(jestPaths.configs); + const missingConfigs = new Set(); + const multipleConfigs = new Set<{ configs: string[]; rel: string }>(); - if (invalid.length) { - const paths = invalid.map((path) => Path.relative(REPO_ROOT, path)).join('\n - '); - log.error( - `The following test files exist outside packages or pre-defined roots:\n - ${paths}` - ); - throw createFailError( - `Move the above files a pre-defined test root, a package, or configure an additional root to handle this file.` - ); - } + for (const testPath of jestPaths.tests) { + const configs = allConfigs + .filter((c) => c.testPaths.has(testPath)) + .map((c) => Path.relative(REPO_ROOT, c.path)) + .sort((a, b) => Path.dirname(a).localeCompare(Path.dirname(b))); - const missing = await findMissingConfigFiles(grouped); + if (configs.length === 0) { + missingConfigs.add(Path.relative(REPO_ROOT, testPath)); + } else if (configs.length > 1) { + multipleConfigs.add({ + configs, + rel: Path.relative(REPO_ROOT, testPath), + }); + } + } - if (missing.length) { + if (missingConfigs.size) { log.error( - `The following Jest config files do not exist for which there are test files for:\n${[ - ...missing, - ] - .map((file) => ` - ${file}`) - .join('\n')}` + `The following test files are not selected by any jest config file:\n${fmtList( + missingConfigs + )}` ); + } - if (fix) { - for (const file of missing) { - const template = - Path.basename(file) === UNIT_CONFIG_NAME - ? unitTestingTemplate - : integrationTestingTemplate; - - const modulePath = Path.dirname(file); - const content = Mustache.render(template, { - relToRoot: Path.relative(modulePath, REPO_ROOT), - modulePath, + if (multipleConfigs.size) { + const overlaps = new Map(); + for (const { configs, rel } of multipleConfigs) { + const key = configs.join(':'); + const group = overlaps.get(key); + if (group) { + group.rels.push(rel); + } else { + overlaps.set(key, { + configs, + rels: [rel], }); - - await Fsp.writeFile(file, content); - log.info('created %s', file); } - } else { - throw createFailError( - `Run 'node scripts/check_jest_configs --fix' to create the missing config files` - ); } + + const list = [...overlaps.values()] + .map( + ({ configs, rels }) => + `configs: ${configs + .map((c) => Path.relative(REPO_ROOT, c)) + .join(', ')}\ntests:\n${fmtList(rels)}` + ) + .join('\n\n'); + + log.error(`The following test files are selected by multiple config files:\n${list}`); + } + + if (missingConfigs.size || multipleConfigs.size) { + throw createFailError('Please resolve the previously logged issues.'); } + + log.success('Checked all jest config files in', fmtMs(performance.now() - start)); }, { - description: 'Check that all test files are covered by a Jest config', - flags: { - boolean: ['fix'], - help: ` - --fix Attempt to create missing config files - `, - }, + description: 'Check that all test files are covered by one, and only one, Jest config', } ); } diff --git a/src/core/jest.config.js b/src/core/jest.config.js deleted file mode 100644 index 66e23cc0ab12b8..00000000000000 --- a/src/core/jest.config.js +++ /dev/null @@ -1,14 +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. - */ - -module.exports = { - preset: '@kbn/test', - rootDir: '../..', - roots: ['/src/core'], - testRunner: 'jasmine2', -}; diff --git a/src/plugins/chart_expressions/jest.config.js b/src/plugins/chart_expressions/jest.config.js deleted file mode 100644 index 503ef441c03590..00000000000000 --- a/src/plugins/chart_expressions/jest.config.js +++ /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 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. - */ - -module.exports = { - preset: '@kbn/test', - rootDir: '../../..', - roots: ['/src/plugins/chart_expressions'], -}; diff --git a/src/plugins/vis_types/jest.config.js b/src/plugins/vis_types/jest.config.js deleted file mode 100644 index af7f2b462b89f3..00000000000000 --- a/src/plugins/vis_types/jest.config.js +++ /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 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. - */ - -module.exports = { - preset: '@kbn/test', - rootDir: '../../..', - roots: ['/src/plugins/vis_types'], -}; diff --git a/test/analytics/jest.config.js b/test/analytics/jest.config.js deleted file mode 100644 index 0e9f9b9be251d3..00000000000000 --- a/test/analytics/jest.config.js +++ /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 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. - */ - -module.exports = { - preset: '@kbn/test', - rootDir: '../..', - roots: ['/test/analytics'], -}; diff --git a/yarn.lock b/yarn.lock index 57d384e8f4a029..e388ecd6ec4577 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2563,7 +2563,7 @@ "@types/istanbul-reports" "^1.1.1" "@types/yargs" "^13.0.0" -"@jest/types@^26.6.2": +"@jest/types@^26", "@jest/types@^26.6.2": version "26.6.2" resolved "https://registry.yarnpkg.com/@jest/types/-/types-26.6.2.tgz#bef5a532030e1d88a2f5a6d933f84e97226ed48e" integrity sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ== @@ -18386,7 +18386,7 @@ jest-cli@^26.6.3: prompts "^2.0.1" yargs "^15.4.1" -jest-config@^26.6.3: +jest-config@^26, jest-config@^26.6.3: version "26.6.3" resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-26.6.3.tgz#64f41444eef9eb03dc51d5c53b75c8c71f645349" integrity sha512-t5qdIj/bCj2j7NFVHb2nFB4aUdfucDn3JRKgrZnplb8nieAirAzRSHP8uDEd+qV6ygzg9Pz4YG7UTJf94LPSyg== @@ -18799,7 +18799,7 @@ jest-runner@^26.6.3: source-map-support "^0.5.6" throat "^5.0.0" -jest-runtime@^26.6.3: +jest-runtime@^26, jest-runtime@^26.6.3: version "26.6.3" resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-26.6.3.tgz#4f64efbcfac398331b74b4b3c82d27d401b8fa2b" integrity sha512-lrzyR3N8sacTAMeonbqpnSka1dHNux2uk0qqDXVkMv2c/A3wYnvQ4EXuI013Y6+gSKSCxdaczvf4HF0mVXHRdw== From f5688a68a5ee27573c3ee36bc4652654c3e60729 Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 8 Jul 2022 08:54:56 -0500 Subject: [PATCH 10/47] [ftr] rework kibana arg parsing, extend loggers correctly (#135944) --- .../lib/kibana_cli_args.test.ts | 97 ++++++++++++ .../functional_tests/lib/kibana_cli_args.ts | 147 ++++++++++++++++++ .../src/functional_tests/lib/paths.ts | 2 +- .../functional_tests/lib/run_kibana_server.ts | 115 ++++---------- packages/kbn-test/src/index.ts | 2 + test/common/config.js | 18 ++- x-pack/test/fleet_api_integration/config.ts | 22 ++- x-pack/test/fleet_cypress/config.ts | 17 +- x-pack/test/fleet_functional/config.ts | 17 +- .../functional_execution_context/config.ts | 33 ++-- 10 files changed, 349 insertions(+), 121 deletions(-) create mode 100644 packages/kbn-test/src/functional_tests/lib/kibana_cli_args.test.ts create mode 100644 packages/kbn-test/src/functional_tests/lib/kibana_cli_args.ts diff --git a/packages/kbn-test/src/functional_tests/lib/kibana_cli_args.test.ts b/packages/kbn-test/src/functional_tests/lib/kibana_cli_args.test.ts new file mode 100644 index 00000000000000..7c38dc091c1fd9 --- /dev/null +++ b/packages/kbn-test/src/functional_tests/lib/kibana_cli_args.test.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { + getArgValue, + getKibanaCliArg, + getKibanaCliLoggers, + parseRawFlags, +} from './kibana_cli_args'; + +describe('parseRawFlags()', () => { + it('produces a sorted list of flags', () => { + expect(parseRawFlags(['--foo=bar', '--a=b', '--c.b.a=0', '--a.b.c=1'])).toMatchInlineSnapshot(` + Array [ + "--a=b", + "--foo=bar", + "--a.b.c=1", + "--c.b.a=0", + ] + `); + }); + + it('validates that bare values are not used', () => { + expect(() => parseRawFlags(['--foo', 'bar'])).toThrowErrorMatchingInlineSnapshot( + `"invalid CLI arg [bar], all args must start with \\"--\\" and values must be specified after an \\"=\\" in a single string per arg"` + ); + }); + + it('deduplciates --base-path, --no-base-path, and --server.basePath', () => { + expect(parseRawFlags(['--no-base-path', '--server.basePath=foo', '--base-path=bar'])) + .toMatchInlineSnapshot(` + Array [ + "--base-path=bar", + ] + `); + }); + + it('allows duplicates for --plugin-path', () => { + expect(parseRawFlags(['--plugin-path=foo', '--plugin-path=bar'])).toMatchInlineSnapshot(` + Array [ + "--plugin-path=foo", + "--plugin-path=bar", + ] + `); + }); +}); + +describe('getArgValue()', () => { + const args = parseRawFlags(['--foo=bar', '--bar=baz', '--foo=foo']); + + it('extracts the value of a specific flag by name', () => { + expect(getArgValue(args, 'foo')).toBe('foo'); + }); +}); + +describe('getKibanaCliArg()', () => { + it('parses the raw flags and then extracts the value', () => { + expect(getKibanaCliArg(['--foo=bar', '--foo=foo'], 'foo')).toBe('foo'); + }); + + it('parses the value as JSON if valid', () => { + expect(getKibanaCliArg(['--foo=["foo"]'], 'foo')).toEqual(['foo']); + expect(getKibanaCliArg(['--foo=null'], 'foo')).toBe(null); + expect(getKibanaCliArg(['--foo=1'], 'foo')).toBe(1); + expect(getKibanaCliArg(['--foo=10.10'], 'foo')).toBe(10.1); + }); + + it('returns an array for flags which are valid duplicates', () => { + expect(getKibanaCliArg(['--plugin-path=foo', '--plugin-path=bar'], 'plugin-path')).toEqual([ + 'foo', + 'bar', + ]); + }); +}); + +describe('getKibanaCliLoggers()', () => { + it('parses the --logging.loggers value to an array', () => { + expect(getKibanaCliLoggers(['--logging.loggers=[{"foo":1}]'])).toEqual([ + { + foo: 1, + }, + ]); + }); + + it('returns an array for invalid values', () => { + expect(getKibanaCliLoggers([])).toEqual([]); + expect(getKibanaCliLoggers(['--logging.loggers=null'])).toEqual([]); + expect(getKibanaCliLoggers(['--logging.loggers.foo=name'])).toEqual([]); + expect(getKibanaCliLoggers(['--logging.loggers={}'])).toEqual([]); + expect(getKibanaCliLoggers(['--logging.loggers=1'])).toEqual([]); + }); +}); diff --git a/packages/kbn-test/src/functional_tests/lib/kibana_cli_args.ts b/packages/kbn-test/src/functional_tests/lib/kibana_cli_args.ts new file mode 100644 index 00000000000000..7fc20cd934a312 --- /dev/null +++ b/packages/kbn-test/src/functional_tests/lib/kibana_cli_args.ts @@ -0,0 +1,147 @@ +/* + * 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. + */ + +/** + * These aliases are used to ensure the values for different flags are collected in a single set. + */ +const ALIASES = new Map([ + // --base-path and --no-base-path inform the same config as `server.basePath`, so deduplicate them + // by treating "base-path" as an alias for "server.basePath" + ['base-path', 'server.basePath'], +]); + +/** + * These are the only flag names that allow duplicate definitions + */ +const ALLOW_DUPLICATES = new Set(['plugin-path']); + +export type KibanaCliArg = string & { + readonly __cliArgBrand: unique symbol; +}; + +/** + * Ensure that cli args are always specified as ["--flag=value", "--other-flag"] and not ["--flag", "value"] + */ +function assertCliArg(arg: string): asserts arg is KibanaCliArg { + if (!arg.startsWith('--')) { + throw new Error( + `invalid CLI arg [${arg}], all args must start with "--" and values must be specified after an "=" in a single string per arg` + ); + } +} + +/** + * Get the name of an arg, stripping the `--` and `no-` prefixes, and values + * + * --no-base-path => base-path + * --base-path => base-path + * --server.basePath=foo => server.basePath + */ +function argName(arg: KibanaCliArg) { + const unflagged = arg.slice(2); + const i = unflagged.indexOf('='); + const withoutValue = i === -1 ? unflagged : unflagged.slice(0, i); + return withoutValue.startsWith('no-') ? withoutValue.slice(3) : withoutValue; +} + +export type ArgValue = boolean | string | number | Record | unknown[] | null; + +const argToValue = (arg: KibanaCliArg): ArgValue => { + if (arg.startsWith('--no-')) { + return false; + } + + const i = arg.indexOf('='); + if (i === -1) { + return true; + } + + const value = arg.slice(i + 1); + try { + return JSON.parse(value); + } catch (error) { + return value; + } +}; + +/** + * Get the value of an arg from the CliArg flags. + */ +export function getArgValue(args: KibanaCliArg[], name: string): ArgValue | ArgValue[] | undefined { + if (ALLOW_DUPLICATES.has(name)) { + return args.filter((a) => argName(a) === name).map(argToValue); + } + + for (const arg of args) { + if (argName(arg) === name) { + return argToValue(arg); + } + } +} + +export function parseRawFlags(rawFlags: string[]) { + // map of CliArg values by their name, this allows us to deduplicate flags and ensure + // that the last flag wins + const cliArgs = new Map(); + + for (const arg of rawFlags) { + assertCliArg(arg); + let name = argName(arg); + const alias = ALIASES.get(name); + if (alias !== undefined) { + name = alias; + } + + const existing = cliArgs.get(name); + const allowsDuplicate = ALLOW_DUPLICATES.has(name); + + if (!existing || !allowsDuplicate) { + cliArgs.set(name, arg); + continue; + } + + if (Array.isArray(existing)) { + existing.push(arg); + } else { + cliArgs.set(name, [existing, arg]); + } + } + + return [...cliArgs.entries()] + .sort(([a], [b]) => { + const aDot = a.includes('.'); + const bDot = b.includes('.'); + return aDot === bDot ? a.localeCompare(b) : aDot ? 1 : -1; + }) + .map((a) => a[1]) + .flat(); +} + +/** + * Parse a list of Kibana CLI Arg flags and find the flag with the given name. If the flag has no + * value then a boolean will be returned (assumed to be a switch flag). If the flag does have a value + * that can be parsed by `JSON.stringify()` the parsed result is returned. Otherwise the raw string + * value is returned. + */ +export function getKibanaCliArg(rawFlags: string[], name: string) { + return getArgValue(parseRawFlags(rawFlags), name); +} + +/** + * Parse the list of Kibana CLI Arg flags and extract the loggers config so that they can be extended + * in a subsequent FTR config + */ +export function getKibanaCliLoggers(rawFlags: string[]) { + const value = getKibanaCliArg(rawFlags, 'logging.loggers'); + + if (Array.isArray(value)) { + return value; + } + + return []; +} diff --git a/packages/kbn-test/src/functional_tests/lib/paths.ts b/packages/kbn-test/src/functional_tests/lib/paths.ts index 75a654fdfc5135..76357d447dc2ab 100644 --- a/packages/kbn-test/src/functional_tests/lib/paths.ts +++ b/packages/kbn-test/src/functional_tests/lib/paths.ts @@ -16,6 +16,6 @@ function resolveRelative(path: string) { } export const KIBANA_EXEC = 'node'; -export const KIBANA_EXEC_PATH = resolveRelative('scripts/kibana'); +export const KIBANA_SCRIPT_PATH = resolveRelative('scripts/kibana'); export const KIBANA_ROOT = REPO_ROOT; export const KIBANA_FTR_SCRIPT = resolve(KIBANA_ROOT, 'scripts/functional_test_runner'); diff --git a/packages/kbn-test/src/functional_tests/lib/run_kibana_server.ts b/packages/kbn-test/src/functional_tests/lib/run_kibana_server.ts index 2e1e2889daf45c..7b6d6d5cd1a3e9 100644 --- a/packages/kbn-test/src/functional_tests/lib/run_kibana_server.ts +++ b/packages/kbn-test/src/functional_tests/lib/run_kibana_server.ts @@ -5,17 +5,21 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ + +import Path from 'path'; + import type { ProcRunner } from '@kbn/dev-proc-runner'; -import { resolve, relative } from 'path'; -import { KIBANA_ROOT, KIBANA_EXEC, KIBANA_EXEC_PATH } from './paths'; + +import { KIBANA_ROOT, KIBANA_EXEC, KIBANA_SCRIPT_PATH } from './paths'; import type { Config } from '../../functional_test_runner'; +import { parseRawFlags } from './kibana_cli_args'; function extendNodeOptions(installDir?: string) { if (!installDir) { return {}; } - const testOnlyRegisterPath = relative( + const testOnlyRegisterPath = Path.relative( installDir, require.resolve('./babel_register_for_test_plugins') ); @@ -40,15 +44,31 @@ export async function runKibanaServer({ }) { const runOptions = config.get('kbnTestServer.runOptions'); const installDir = runOptions.alwaysUseSource ? undefined : options.installDir; - const env = config.get('kbnTestServer.env'); + const extraArgs = options.extraKbnOpts ?? []; + + const buildArgs: string[] = config.get('kbnTestServer.buildArgs') || []; + const sourceArgs: string[] = config.get('kbnTestServer.sourceArgs') || []; + const serverArgs: string[] = config.get('kbnTestServer.serverArgs') || []; + + const args = parseRawFlags([ + // When installDir is passed, we run from a built version of Kibana which uses different command line + // arguments. If installDir is not passed, we run from source code. + ...(installDir + ? [...buildArgs, ...serverArgs.filter((a: string) => a !== '--oss')] + : [...sourceArgs, ...serverArgs]), + + // We also allow passing in extra Kibana server options, tack those on here so they always take precedence + ...extraArgs, + ]); + // main process await procs.run('kibana', { cmd: getKibanaCmd(installDir), - args: filterCliArgs(collectCliArgs(config, installDir, options.extraKbnOpts)), + args: installDir ? args : [KIBANA_SCRIPT_PATH, ...args], env: { FORCE_COLOR: 1, ...process.env, - ...env, + ...config.get('kbnTestServer.env'), ...extendNodeOptions(installDir), }, cwd: installDir || KIBANA_ROOT, @@ -60,88 +80,9 @@ export async function runKibanaServer({ function getKibanaCmd(installDir?: string) { if (installDir) { return process.platform.startsWith('win') - ? resolve(installDir, 'bin/kibana.bat') - : resolve(installDir, 'bin/kibana'); + ? Path.resolve(installDir, 'bin/kibana.bat') + : Path.resolve(installDir, 'bin/kibana'); } return KIBANA_EXEC; } - -/** - * When installDir is passed, we run from a built version of Kibana, - * which uses different command line arguments. If installDir is not - * passed, we run from source code. We also allow passing in extra - * Kibana server options, so we tack those on here. - */ -function collectCliArgs(config: Config, installDir?: string, extraKbnOpts: string[] = []) { - const buildArgs: string[] = config.get('kbnTestServer.buildArgs') || []; - const sourceArgs: string[] = config.get('kbnTestServer.sourceArgs') || []; - const serverArgs: string[] = config.get('kbnTestServer.serverArgs') || []; - - return pipe( - serverArgs, - (args) => (installDir ? args.filter((a: string) => a !== '--oss') : args), - (args) => (installDir ? [...buildArgs, ...args] : [KIBANA_EXEC_PATH, ...sourceArgs, ...args]), - (args) => args.concat(extraKbnOpts) - ); -} - -/** - * Filter the cli args to remove duplications and - * overridden options - */ -function filterCliArgs(args: string[]) { - return args.reduce((acc, val, ind) => { - // If original argv has a later basepath setting, skip this val. - if (isBasePathSettingOverridden(args, val, ind)) { - return acc; - } - - // Check if original argv has a later setting that overrides - // the current val. If so, skip this val. - if ( - !allowsDuplicate(val) && - findIndexFrom(args, ++ind, (opt) => opt.split('=')[0] === val.split('=')[0]) > -1 - ) { - return acc; - } - - return [...acc, val]; - }, [] as string[]); -} - -/** - * Apply each function in fns to the result of the - * previous function. The first function's input - * is the arr array. - */ -function pipe(arr: any[], ...fns: Array<(...args: any[]) => any>) { - return fns.reduce((acc, fn) => { - return fn(acc); - }, arr); -} - -/** - * Checks whether a specific parameter is allowed to appear multiple - * times in the Kibana parameters. - */ -function allowsDuplicate(val: string) { - return ['--plugin-path'].includes(val.split('=')[0]); -} - -function isBasePathSettingOverridden(args: string[], val: string, index: number) { - const key = val.split('=')[0]; - const basePathKeys = ['--no-base-path', '--server.basePath']; - - if (basePathKeys.includes(key)) { - if (findIndexFrom(args, ++index, (opt) => basePathKeys.includes(opt.split('=')[0])) > -1) { - return true; - } - } - - return false; -} - -function findIndexFrom(array: string[], index: number, predicate: (element: string) => boolean) { - return [...array].slice(index).findIndex(predicate); -} diff --git a/packages/kbn-test/src/index.ts b/packages/kbn-test/src/index.ts index ea4cfb0cd0fba5..7770232011d21c 100644 --- a/packages/kbn-test/src/index.ts +++ b/packages/kbn-test/src/index.ts @@ -27,6 +27,8 @@ export { runTests, startServers } from './functional_tests/tasks'; // @internal export { KIBANA_ROOT } from './functional_tests/lib/paths'; +export { getKibanaCliArg, getKibanaCliLoggers } from './functional_tests/lib/kibana_cli_args'; + export type { CreateTestEsClusterOptions, EsTestCluster, diff --git a/test/common/config.js b/test/common/config.js index 048d032d0169a6..5079d32909ff57 100644 --- a/test/common/config.js +++ b/test/common/config.js @@ -57,11 +57,19 @@ export default function () { ...(!!process.env.CODE_COVERAGE ? [`--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'coverage')}`] : []), - '--logging.appenders.deprecation.type=console', - '--logging.appenders.deprecation.layout.type=json', - '--logging.loggers[0].name=elasticsearch.deprecation', - '--logging.loggers[0].level=all', - '--logging.loggers[0].appenders[0]=deprecation', + `--logging.appenders.deprecation=${JSON.stringify({ + type: 'console', + layout: { + type: 'json', + }, + })}`, + `--logging.loggers=${JSON.stringify([ + { + name: 'elasticsearch.deprecation', + level: 'all', + appenders: ['deprecation'], + }, + ])}`, ], }, services, diff --git a/x-pack/test/fleet_api_integration/config.ts b/x-pack/test/fleet_api_integration/config.ts index d73904d792955a..d968dcc6c6d1d6 100644 --- a/x-pack/test/fleet_api_integration/config.ts +++ b/x-pack/test/fleet_api_integration/config.ts @@ -7,8 +7,11 @@ import path from 'path'; -import { FtrConfigProviderContext } from '@kbn/test'; -import { defineDockerServersConfig } from '@kbn/test'; +import { + FtrConfigProviderContext, + defineDockerServersConfig, + getKibanaCliLoggers, +} from '@kbn/test'; // Docker image to use for Fleet API integration tests. // This hash comes from the latest successful build of the Snapshot Distribution of the Package Registry, for @@ -67,10 +70,17 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...(registryPort ? [`--xpack.fleet.registryUrl=http://localhost:${registryPort}`] : []), `--xpack.fleet.developer.bundledPackageLocation=${BUNDLED_PACKAGE_DIR}`, '--xpack.cloudSecurityPosture.enabled=true', - // Enable debug fleet logs by default - `--logging.loggers[0].name=plugins.fleet`, - `--logging.loggers[0].level=debug`, - `--logging.loggers[0].appenders=${JSON.stringify(['default'])}`, + + `--logging.loggers=${JSON.stringify([ + ...getKibanaCliLoggers(xPackAPITestsConfig.get('kbnTestServer.serverArgs')), + + // Enable debug fleet logs by default + { + name: 'plugins.fleet', + level: 'debug', + appenders: ['default'], + }, + ])}`, ], }, }; diff --git a/x-pack/test/fleet_cypress/config.ts b/x-pack/test/fleet_cypress/config.ts index 52198f4f035e0f..4eb3137fa4b53b 100644 --- a/x-pack/test/fleet_cypress/config.ts +++ b/x-pack/test/fleet_cypress/config.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { FtrConfigProviderContext } from '@kbn/test'; +import { FtrConfigProviderContext, getKibanaCliLoggers } from '@kbn/test'; import { CA_CERT_PATH } from '@kbn/dev-utils'; @@ -38,10 +38,17 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { '--csp.strict=false', // define custom kibana server args here `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, - // Enable debug fleet logs by default - `--logging.loggers[0].name=plugins.fleet`, - `--logging.loggers[0].level=debug`, - `--logging.loggers[0].appenders=${JSON.stringify(['default'])}`, + + `--logging.loggers=${JSON.stringify([ + ...getKibanaCliLoggers(xpackFunctionalTestsConfig.get('kbnTestServer.serverArgs')), + + // Enable debug fleet logs by default + { + name: 'plugins.fleet', + level: 'debug', + appenders: ['default'], + }, + ])}`, ], }, }; diff --git a/x-pack/test/fleet_functional/config.ts b/x-pack/test/fleet_functional/config.ts index 5efc39b02acd6e..5082a54858bf3c 100644 --- a/x-pack/test/fleet_functional/config.ts +++ b/x-pack/test/fleet_functional/config.ts @@ -6,7 +6,7 @@ */ import { resolve } from 'path'; -import { FtrConfigProviderContext } from '@kbn/test'; +import { FtrConfigProviderContext, getKibanaCliLoggers } from '@kbn/test'; import { pageObjects } from './page_objects'; import { services } from './services'; @@ -33,10 +33,17 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...xpackFunctionalConfig.get('kbnTestServer'), serverArgs: [ ...xpackFunctionalConfig.get('kbnTestServer.serverArgs'), - // Enable debug fleet logs by default - `--logging.loggers[0].name=plugins.fleet`, - `--logging.loggers[0].level=debug`, - `--logging.loggers[0].appenders=${JSON.stringify(['default'])}`, + + `--logging.loggers=${JSON.stringify([ + ...getKibanaCliLoggers(xpackFunctionalConfig.get('kbnTestServer.serverArgs')), + + // Enable debug fleet logs by default + { + name: 'plugins.fleet', + level: 'debug', + appenders: ['default'], + }, + ])}`, ], }, layout: { diff --git a/x-pack/test/functional_execution_context/config.ts b/x-pack/test/functional_execution_context/config.ts index caf88769b6a065..767ae9d18d7d36 100644 --- a/x-pack/test/functional_execution_context/config.ts +++ b/x-pack/test/functional_execution_context/config.ts @@ -6,7 +6,7 @@ */ import Path from 'path'; import { CA_CERT_PATH } from '@kbn/dev-utils'; -import { FtrConfigProviderContext } from '@kbn/test'; +import { FtrConfigProviderContext, getKibanaCliLoggers } from '@kbn/test'; import { logFilePath } from './test_utils'; const alertTestPlugin = Path.resolve(__dirname, './fixtures/plugins/alerts'); @@ -42,23 +42,32 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { `--elasticsearch.hosts=${servers.elasticsearch.protocol}://${servers.elasticsearch.hostname}:${servers.elasticsearch.port}`, `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, + `--xpack.alerting.rules.minimumScheduleInterval.value="1s"`, + '--server.requestId.allowFromAnyIp=true', '--logging.appenders.file.type=file', `--logging.appenders.file.fileName=${logFilePath}`, '--logging.appenders.file.layout.type=json', - '--logging.loggers[0].name=elasticsearch.query', - '--logging.loggers[0].level=all', - `--logging.loggers[0].appenders=${JSON.stringify(['file'])}`, + `--logging.loggers=${JSON.stringify([ + ...getKibanaCliLoggers(functionalConfig.get('kbnTestServer.serverArgs')), - '--logging.loggers[1].name=execution_context', - '--logging.loggers[1].level=debug', - `--logging.loggers[1].appenders=${JSON.stringify(['file'])}`, - - '--logging.loggers[2].name=http.server.response', - '--logging.loggers[2].level=all', - `--logging.loggers[2].appenders=${JSON.stringify(['file'])}`, - `--xpack.alerting.rules.minimumScheduleInterval.value="1s"`, + { + name: 'elasticsearch.query', + level: 'all', + appenders: ['file'], + }, + { + name: 'execution_context', + level: 'debug', + appenders: ['file'], + }, + { + name: 'http.server.response', + level: 'all', + appenders: ['file'], + }, + ])}`, ], }, }; From e6ef1bb5d279759fb08e7e0b643dce272eabf4c1 Mon Sep 17 00:00:00 2001 From: Marc Guasch Date: Fri, 8 Jul 2022 16:24:09 +0200 Subject: [PATCH 11/47] [Fleet] Add support for a textarea type in integrations (#133070) * Add support for a textarea type in integrations * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/fleet/common/types/models/epm.ts | 9 ++++++++- .../components/package_policy_input_var_field.tsx | 12 ++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 890149640be78f..e177bcba0ebb25 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -335,7 +335,14 @@ export interface RegistryDataStreamPrivileges { indices?: string[]; } -export type RegistryVarType = 'integer' | 'bool' | 'password' | 'text' | 'yaml' | 'string'; +export type RegistryVarType = + | 'integer' + | 'bool' + | 'password' + | 'text' + | 'yaml' + | 'string' + | 'textarea'; export enum RegistryVarsEntryKeys { name = 'name', title = 'title', diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_var_field.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_var_field.tsx index 563dec04449b35..327037a6f27456 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_var_field.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_var_field.tsx @@ -16,6 +16,7 @@ import { EuiText, EuiFieldPassword, EuiCodeBlock, + EuiTextArea, } from '@elastic/eui'; import styled from 'styled-components'; @@ -55,6 +56,17 @@ export const PackagePolicyInputVarField: React.FunctionComponent<{ ); } switch (type) { + case 'textarea': + return ( + onChange(e.target.value)} + onBlur={() => setIsDirty(true)} + disabled={frozen} + resize="vertical" + /> + ); case 'yaml': return frozen ? ( From 45e7fe6ead5b77069ecdab72e110648f4a6f71bf Mon Sep 17 00:00:00 2001 From: Marshall Main <55718608+marshallmain@users.noreply.github.com> Date: Fri, 8 Jul 2022 07:44:26 -0700 Subject: [PATCH 12/47] Allow related_integrations, required fields, and setup to be set in some cases (#135955) --- .../schemas/rule_converters.ts | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) 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 4fd92e87614f67..51d45b0c7227fb 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 @@ -36,7 +36,12 @@ import { InternalRuleUpdate, } from './rule_schemas'; import { assertUnreachable } from '../../../../common/utility_types'; -import { RuleExecutionSummary } from '../../../../common/detection_engine/schemas/common'; +import { + RelatedIntegrationArray, + RequiredFieldArray, + RuleExecutionSummary, + SetupGuide, +} from '../../../../common/detection_engine/schemas/common'; import { CreateRulesSchema, CreateTypeSpecific, @@ -340,7 +345,11 @@ const shouldUpdateVersion = (params: PatchRulesSchema): boolean => { // eslint-disable-next-line complexity export const convertPatchAPIToInternalSchema = ( - params: PatchRulesSchema, + params: PatchRulesSchema & { + related_integrations?: RelatedIntegrationArray; + required_fields?: RequiredFieldArray; + setup?: SetupGuide; + }, existingRule: SanitizedRule ): InternalRuleUpdate => { const typeSpecificParams = patchTypeSpecificSnakeToCamel(params, existingRule.params); @@ -362,12 +371,12 @@ export const convertPatchAPIToInternalSchema = ( timelineTitle: params.timeline_title ?? existingParams.timelineTitle, meta: params.meta ?? existingParams.meta, maxSignals: params.max_signals ?? existingParams.maxSignals, - relatedIntegrations: existingParams.relatedIntegrations, - requiredFields: existingParams.requiredFields, + relatedIntegrations: params.related_integrations ?? existingParams.relatedIntegrations, + requiredFields: params.required_fields ?? existingParams.requiredFields, riskScore: params.risk_score ?? existingParams.riskScore, riskScoreMapping: params.risk_score_mapping ?? existingParams.riskScoreMapping, ruleNameOverride: params.rule_name_override ?? existingParams.ruleNameOverride, - setup: existingParams.setup, + setup: params.setup ?? existingParams.setup, severity: params.severity ?? existingParams.severity, severityMapping: params.severity_mapping ?? existingParams.severityMapping, threat: params.threat ?? existingParams.threat, @@ -395,7 +404,11 @@ export const convertPatchAPIToInternalSchema = ( }; export const convertCreateAPIToInternalSchema = ( - input: CreateRulesSchema, + input: CreateRulesSchema & { + related_integrations?: RelatedIntegrationArray; + required_fields?: RequiredFieldArray; + setup?: SetupGuide; + }, immutable = false, defaultEnabled = true ): InternalRuleCreate => { @@ -433,9 +446,9 @@ export const convertCreateAPIToInternalSchema = ( note: input.note, version: input.version ?? 1, exceptionsList: input.exceptions_list ?? [], - relatedIntegrations: [], - requiredFields: [], - setup: '', + relatedIntegrations: input.related_integrations ?? [], + requiredFields: input.required_fields ?? [], + setup: input.setup ?? '', ...typeSpecificParams, }, schedule: { interval: input.interval ?? '5m' }, From dd7d92f282cbb54921ac845568b5d7ff14b3e51a Mon Sep 17 00:00:00 2001 From: Julia Bardi <90178898+juliaElastic@users.noreply.github.com> Date: Fri, 8 Jul 2022 16:50:00 +0200 Subject: [PATCH 13/47] [Fleet] Changed rename tag feature to include a Rename button (#135901) * fixed bug with debounce * changed auto update to rename button * improved tag update messages * fixed tests * rename on enter key, made rename button smaller * improved logic so that tag will be renamed in place * rename tag when clicking outside * using event types * removed rename button Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/tag_options.test.tsx | 30 ++--- .../components/tag_options.tsx | 83 ++++++++++---- .../components/tags_add_remove.test.tsx | 46 +++++++- .../components/tags_add_remove.tsx | 31 +++++- .../agent_list_page/hooks/use_update_tags.tsx | 48 +++++--- .../services/agents/update_agent_tags.test.ts | 103 ++++++++++++++++++ .../services/agents/update_agent_tags.ts | 19 +++- 7 files changed, 296 insertions(+), 64 deletions(-) create mode 100644 x-pack/plugins/fleet/server/services/agents/update_agent_tags.test.ts diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tag_options.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tag_options.test.tsx index f4b72f9e9b53b3..584a92a60310fa 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tag_options.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tag_options.test.tsx @@ -6,17 +6,12 @@ */ import React from 'react'; -import { debounce } from 'lodash'; import { render, fireEvent, waitFor } from '@testing-library/react'; import { useUpdateTags } from '../hooks'; import { TagOptions } from './tag_options'; -jest.mock('lodash', () => ({ - debounce: jest.fn(), -})); - jest.mock('../hooks', () => ({ useUpdateTags: jest.fn().mockReturnValue({ bulkUpdateTags: jest.fn(), @@ -33,10 +28,6 @@ describe('TagOptions', () => { mockBulkUpdateTags.mockReset(); mockBulkUpdateTags.mockResolvedValue({}); isTagHovered = true; - (debounce as jest.Mock).mockImplementationOnce((fn) => (newName: string) => { - fn(newName); - onTagsUpdated(); - }); }); const renderComponent = () => { @@ -58,17 +49,24 @@ describe('TagOptions', () => { }); }); - it('should delete tag when button is clicked', () => { + it('should delete tag when delete button is clicked', () => { const result = renderComponent(); fireEvent.click(result.getByRole('button')); fireEvent.click(result.getByText('Delete tag')); - expect(mockBulkUpdateTags).toHaveBeenCalledWith('tags:agent', [], ['agent'], expect.anything()); + expect(mockBulkUpdateTags).toHaveBeenCalledWith( + 'tags:agent', + [], + ['agent'], + expect.anything(), + 'Tag deleted', + 'Tag delete failed' + ); }); - it('should rename tag when name input is changed', async () => { + it('should rename tag when enter key is pressed', async () => { const result = renderComponent(); fireEvent.click(result.getByRole('button')); @@ -77,13 +75,17 @@ describe('TagOptions', () => { fireEvent.input(nameInput, { target: { value: 'newName' }, }); + fireEvent.keyDown(nameInput, { + key: 'Enter', + }); - expect(onTagsUpdated).toHaveBeenCalled(); expect(mockBulkUpdateTags).toHaveBeenCalledWith( 'tags:agent', ['newName'], ['agent'], - expect.anything() + expect.anything(), + 'Tag renamed', + 'Tag rename failed' ); }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tag_options.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tag_options.tsx index 62d25d83938b15..994fb1b64880ec 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tag_options.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tag_options.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import React, { useMemo, useState, useEffect } from 'react'; -import { debounce } from 'lodash'; +import type { MouseEvent, ChangeEvent } from 'react'; +import React, { useState, useEffect } from 'react'; import { EuiButtonEmpty, EuiButtonIcon, @@ -30,29 +30,65 @@ interface Props { export const TagOptions: React.FC = ({ tagName, isTagHovered, onTagsUpdated }: Props) => { const [tagOptionsVisible, setTagOptionsVisible] = useState(false); const [tagOptionsButton, setTagOptionsButton] = useState(); - const [tagMenuButtonVisible, setTagMenuButtonVisible] = useState(isTagHovered); + const [updatedName, setUpdatedName] = useState(tagName); useEffect(() => { setTagMenuButtonVisible(isTagHovered || tagOptionsVisible); }, [isTagHovered, tagOptionsVisible]); - const [updatedName, setUpdatedName] = useState(tagName); + useEffect(() => { + setUpdatedName(tagName); + }, [tagName]); - const closePopover = () => setTagOptionsVisible(false); + const closePopover = (isDelete = false) => { + setTagOptionsVisible(false); + if (isDelete) { + handleDelete(); + } else { + handleRename(updatedName); + } + }; const updateTagsHook = useUpdateTags(); + const bulkUpdateTags = updateTagsHook.bulkUpdateTags; const TAGS_QUERY = 'tags:{name}'; - const debouncedSendRenameTag = useMemo( - () => - debounce((newName: string) => { - const kuery = TAGS_QUERY.replace('{name}', tagName); - updateTagsHook.bulkUpdateTags(kuery, [newName], [tagName], () => onTagsUpdated()); - }, 1000), - [onTagsUpdated, tagName, updateTagsHook] - ); + const handleRename = (newName?: string) => { + if (newName === tagName || !newName) { + return; + } + const kuery = TAGS_QUERY.replace('{name}', tagName); + bulkUpdateTags( + kuery, + [newName], + [tagName], + () => onTagsUpdated(), + i18n.translate('xpack.fleet.renameAgentTags.successNotificationTitle', { + defaultMessage: 'Tag renamed', + }), + i18n.translate('xpack.fleet.renameAgentTags.errorNotificationTitle', { + defaultMessage: 'Tag rename failed', + }) + ); + }; + + const handleDelete = () => { + const kuery = TAGS_QUERY.replace('{name}', tagName); + updateTagsHook.bulkUpdateTags( + kuery, + [], + [tagName], + () => onTagsUpdated(), + i18n.translate('xpack.fleet.deleteAgentTags.successNotificationTitle', { + defaultMessage: 'Tag deleted', + }), + i18n.translate('xpack.fleet.deleteAgentTags.errorNotificationTitle', { + defaultMessage: 'Tag delete failed', + }) + ); + }; return ( <> @@ -63,8 +99,8 @@ export const TagOptions: React.FC = ({ tagName, isTagHovered, onTagsUpdat defaultMessage: 'Tag Options', })} color="text" - onClick={(event: any) => { - setTagOptionsButton(event.target); + onClick={(event: MouseEvent) => { + setTagOptionsButton(event.currentTarget); setTagOptionsVisible(!tagOptionsVisible); }} /> @@ -84,13 +120,14 @@ export const TagOptions: React.FC = ({ tagName, isTagHovered, onTagsUpdat })} value={updatedName} required - onChange={(e: any) => { - const newName = e.target.value; - setUpdatedName(newName); - if (!newName) { - return; + onKeyDown={(e: { key: string }) => { + if (e.key === 'Enter') { + closePopover(); } - debouncedSendRenameTag(newName); + }} + onChange={(e: ChangeEvent) => { + const newName = e.currentTarget.value; + setUpdatedName(newName); }} /> @@ -99,9 +136,7 @@ export const TagOptions: React.FC = ({ tagName, isTagHovered, onTagsUpdat size="s" color="danger" onClick={() => { - const kuery = TAGS_QUERY.replace('{name}', tagName); - updateTagsHook.bulkUpdateTags(kuery, [], [tagName], () => onTagsUpdated()); - closePopover(); + closePopover(true); }} > {' '} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags_add_remove.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags_add_remove.test.tsx index 4d4da36b9cb5f1..4467f8413d5833 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags_add_remove.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags_add_remove.test.tsx @@ -57,7 +57,13 @@ describe('TagsAddRemove', () => { fireEvent.click(getTag('tag2')); - expect(mockUpdateTags).toHaveBeenCalledWith('agent1', ['tag1', 'tag2'], expect.anything()); + expect(mockUpdateTags).toHaveBeenCalledWith( + 'agent1', + ['tag1', 'tag2'], + expect.anything(), + undefined, + undefined + ); }); it('should remove selected tag when previously selected', async () => { @@ -69,7 +75,13 @@ describe('TagsAddRemove', () => { fireEvent.click(getTag('tag1')); - expect(mockUpdateTags).toHaveBeenCalledWith('agent1', [], expect.anything()); + expect(mockUpdateTags).toHaveBeenCalledWith( + 'agent1', + [], + expect.anything(), + undefined, + undefined + ); }); it('should add new tag when not found in search and button clicked', () => { @@ -82,7 +94,13 @@ describe('TagsAddRemove', () => { fireEvent.click(result.getAllByText('Create a new tag "newTag"')[0].closest('button')!); - expect(mockUpdateTags).toHaveBeenCalledWith('agent1', ['tag1', 'newTag'], expect.anything()); + expect(mockUpdateTags).toHaveBeenCalledWith( + 'agent1', + ['tag1', 'newTag'], + expect.anything(), + 'Tag created', + 'Tag creation failed' + ); }); it('should add selected tag when previously unselected - bulk selection', async () => { @@ -94,7 +112,14 @@ describe('TagsAddRemove', () => { fireEvent.click(getTag('tag2')); - expect(mockBulkUpdateTags).toHaveBeenCalledWith('', ['tag2'], [], expect.anything()); + expect(mockBulkUpdateTags).toHaveBeenCalledWith( + '', + ['tag2'], + [], + expect.anything(), + undefined, + undefined + ); }); it('should remove selected tag when previously selected - bulk selection', async () => { @@ -110,7 +135,9 @@ describe('TagsAddRemove', () => { ['agent1', 'agent2'], [], ['tag1'], - expect.anything() + expect.anything(), + undefined, + undefined ); }); @@ -124,7 +151,14 @@ describe('TagsAddRemove', () => { fireEvent.click(result.getAllByText('Create a new tag "newTag"')[0].closest('button')!); - expect(mockBulkUpdateTags).toHaveBeenCalledWith('query', ['newTag'], [], expect.anything()); + expect(mockBulkUpdateTags).toHaveBeenCalledWith( + 'query', + ['newTag'], + [], + expect.anything(), + 'Tag created', + 'Tag creation failed' + ); }); it('should make tag options button visible on mouse enter', async () => { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags_add_remove.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags_add_remove.tsx index 32e3a10d68551c..c60c6cec19fd94 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags_add_remove.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags_add_remove.tsx @@ -64,15 +64,29 @@ export const TagsAddRemove: React.FC = ({ setLabels(labelsFromTags(allTags)); }, [allTags, labelsFromTags]); - const updateTags = async (tagsToAdd: string[], tagsToRemove: string[]) => { + const updateTags = async ( + tagsToAdd: string[], + tagsToRemove: string[], + successMessage?: string, + errorMessage?: string + ) => { if (agentId) { updateTagsHook.updateTags( agentId, difference(selectedTags, tagsToRemove).concat(tagsToAdd), - () => onTagsUpdated() + () => onTagsUpdated(), + successMessage, + errorMessage ); } else { - updateTagsHook.bulkUpdateTags(agents!, tagsToAdd, tagsToRemove, () => onTagsUpdated()); + updateTagsHook.bulkUpdateTags( + agents!, + tagsToAdd, + tagsToRemove, + () => onTagsUpdated(), + successMessage, + errorMessage + ); } }; @@ -136,7 +150,16 @@ export const TagsAddRemove: React.FC = ({ if (!searchValue) { return; } - updateTags([searchValue], []); + updateTags( + [searchValue], + [], + i18n.translate('xpack.fleet.createAgentTags.successNotificationTitle', { + defaultMessage: 'Tag created', + }), + i18n.translate('xpack.fleet.createAgentTags.errorNotificationTitle', { + defaultMessage: 'Tag creation failed', + }) + ); }} > {' '} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_update_tags.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_update_tags.tsx index 7b14d46cb17e05..3e98c817a881bc 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_update_tags.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_update_tags.tsx @@ -18,37 +18,51 @@ export const useUpdateTags = () => { const { notifications } = useStartServices(); const wrapRequest = useCallback( - async (requestFn: () => Promise, onSuccess: () => void) => { + async ( + requestFn: () => Promise, + onSuccess: () => void, + successMessage?: string, + errorMessage?: string + ) => { try { const res = await requestFn(); if (res.error) { throw res.error; } - const successMessage = i18n.translate( - 'xpack.fleet.updateAgentTags.successNotificationTitle', - { + const message = + successMessage ?? + i18n.translate('xpack.fleet.updateAgentTags.successNotificationTitle', { defaultMessage: 'Tags updated', - } - ); - notifications.toasts.addSuccess(successMessage); + }); + notifications.toasts.addSuccess(message); onSuccess(); } catch (error) { - const errorMessage = i18n.translate('xpack.fleet.updateAgentTags.errorNotificationTitle', { - defaultMessage: 'Tags update failed', - }); - notifications.toasts.addError(error, { title: errorMessage }); + const errorTitle = + errorMessage ?? + i18n.translate('xpack.fleet.updateAgentTags.errorNotificationTitle', { + defaultMessage: 'Tags update failed', + }); + notifications.toasts.addError(error, { title: errorTitle }); } }, [notifications.toasts] ); const updateTags = useCallback( - async (agentId: string, newTags: string[], onSuccess: () => void) => { + async ( + agentId: string, + newTags: string[], + onSuccess: () => void, + successMessage?: string, + errorMessage?: string + ) => { await wrapRequest( async () => await sendPutAgentTagsUpdate(agentId, { tags: newTags }), - onSuccess + onSuccess, + successMessage, + errorMessage ); }, [wrapRequest] @@ -59,11 +73,15 @@ export const useUpdateTags = () => { agents: string[] | string, tagsToAdd: string[], tagsToRemove: string[], - onSuccess: () => void + onSuccess: () => void, + successMessage?: string, + errorMessage?: string ) => { await wrapRequest( async () => await sendPostBulkAgentTagsUpdate({ agents, tagsToAdd, tagsToRemove }), - onSuccess + onSuccess, + successMessage, + errorMessage ); }, [wrapRequest] diff --git a/x-pack/plugins/fleet/server/services/agents/update_agent_tags.test.ts b/x-pack/plugins/fleet/server/services/agents/update_agent_tags.test.ts new file mode 100644 index 00000000000000..53bf035903c36d --- /dev/null +++ b/x-pack/plugins/fleet/server/services/agents/update_agent_tags.test.ts @@ -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 type { ElasticsearchClientMock } from '@kbn/core/server/mocks'; +import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; + +import { updateAgentTags } from './update_agent_tags'; + +describe('update_agent_tags', () => { + let esClient: ElasticsearchClientMock; + + beforeEach(() => { + esClient = elasticsearchServiceMock.createInternalClient(); + esClient.mget.mockResolvedValue({ + docs: [ + { + _id: 'agent1', + _source: { + tags: ['one', 'two', 'three'], + }, + } as any, + ], + }); + esClient.bulk.mockResolvedValue({ + items: [], + } as any); + }); + + function expectTagsInEsBulk(tags: string[]) { + expect(esClient.bulk).toHaveBeenCalledWith( + expect.objectContaining({ + body: [ + expect.anything(), + { + doc: expect.objectContaining({ + tags, + }), + }, + ], + }) + ); + } + + it('should replace tag in middle place when one add and one remove tag', async () => { + await updateAgentTags(esClient, { agentIds: ['agent1'] }, ['newName'], ['two']); + + expectTagsInEsBulk(['one', 'newName', 'three']); + }); + + it('should replace tag in first place when one add and one remove tag', async () => { + await updateAgentTags(esClient, { agentIds: ['agent1'] }, ['newName'], ['one']); + + expectTagsInEsBulk(['newName', 'two', 'three']); + }); + + it('should replace tag in last place when one add and one remove tag', async () => { + await updateAgentTags(esClient, { agentIds: ['agent1'] }, ['newName'], ['three']); + + expectTagsInEsBulk(['one', 'two', 'newName']); + }); + + it('should add tag when tagsToRemove does not exist', async () => { + esClient.mget.mockResolvedValue({ + docs: [ + { + _id: 'agent1', + _source: {}, + } as any, + ], + }); + await updateAgentTags(esClient, { agentIds: ['agent1'] }, ['newName'], ['three']); + + expectTagsInEsBulk(['newName']); + }); + + it('should remove duplicate tags', async () => { + await updateAgentTags(esClient, { agentIds: ['agent1'] }, ['one'], ['two']); + + expectTagsInEsBulk(['one', 'three']); + }); + + it('should add tag at the end when no tagsToRemove', async () => { + await updateAgentTags(esClient, { agentIds: ['agent1'] }, ['newName'], []); + + expectTagsInEsBulk(['one', 'two', 'three', 'newName']); + }); + + it('should add tag at the end when tagsToRemove not in existing tags', async () => { + await updateAgentTags(esClient, { agentIds: ['agent1'] }, ['newName'], ['dummy']); + + expectTagsInEsBulk(['one', 'two', 'three', 'newName']); + }); + + it('should add tag at the end when multiple tagsToRemove', async () => { + await updateAgentTags(esClient, { agentIds: ['agent1'] }, ['newName'], ['one', 'two']); + + expectTagsInEsBulk(['three', 'newName']); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/agents/update_agent_tags.ts b/x-pack/plugins/fleet/server/services/agents/update_agent_tags.ts index a23e94c509116b..fed7e5c4e01524 100644 --- a/x-pack/plugins/fleet/server/services/agents/update_agent_tags.ts +++ b/x-pack/plugins/fleet/server/services/agents/update_agent_tags.ts @@ -87,12 +87,29 @@ async function updateTagsBatch( ): Promise<{ items: BulkActionResult[] }> { const errors: Record = { ...outgoingErrors }; + const getNewTags = (agent: Agent): string[] => { + const existingTags = agent.tags ?? []; + + if (tagsToAdd.length === 1 && tagsToRemove.length === 1) { + const removableTagIndex = existingTags.indexOf(tagsToRemove[0]); + if (removableTagIndex > -1) { + const newTags = uniq([ + ...existingTags.slice(0, removableTagIndex), + tagsToAdd[0], + ...existingTags.slice(removableTagIndex + 1), + ]); + return newTags; + } + } + return uniq(difference(existingTags, tagsToRemove).concat(tagsToAdd)); + }; + await bulkUpdateAgents( esClient, givenAgents.map((agent) => ({ agentId: agent.id, data: { - tags: uniq(difference(agent.tags ?? [], tagsToRemove).concat(tagsToAdd)), + tags: getNewTags(agent), }, })) ); From 6b5101014132678f6aabe936a9a96fdea2f6dc85 Mon Sep 17 00:00:00 2001 From: Quynh Nguyen <43350163+qn895@users.noreply.github.com> Date: Fri, 8 Jul 2022 09:57:59 -0500 Subject: [PATCH 14/47] [ML] Transforms: Adds per-transform setting for num_failure_retries to creation wizard and edit flyout and authorization info (#135486) * [ML] Add optional num_failure_retries setting in creation wizard and edit flyout * [ML] Fix logic for input validation * Fix types & i18n * Change to integerRangeMinus1To100 * Fix clone * Fix test * Update text * [ML] Add functional tests * [ML] Add functional tests for editting * [ML] Update translations * [ML] Surface num failure retries to stats * [ML] Add authorization info * [ML] Fix extra period * [ML] Move numberValidator to its own package * [ML] Add tests for cloning * [ML] Update logic + add unit tests * [ML] Fix expected value --- x-pack/packages/ml/agg_utils/src/index.ts | 2 + .../ml/agg_utils/src/validate_number.test.ts | 42 ++++++++++ .../ml/agg_utils/src/validate_number.ts | 48 +++++++++++ x-pack/plugins/ml/common/util/validators.ts | 38 --------- .../plugins/ml/public/alerting/validators.ts | 4 +- .../start_deployment_setup.tsx | 7 +- .../common/api_schemas/transforms.ts | 2 + .../transform/common/types/transform.ts | 1 + .../public/app/common/request.test.ts | 2 + .../transform/public/app/common/request.ts | 3 + .../components/step_details/common.ts | 5 ++ .../step_details/step_details_form.tsx | 69 +++++++++++++++ .../step_details/step_details_summary.tsx | 11 +++ .../edit_transform_flyout_form.tsx | 20 ++++- .../use_edit_transform_flyout.ts | 38 ++++++++- .../transform_list/expanded_row.tsx | 80 +++++++++++------- .../translations/translations/fr-FR.json | 6 +- .../translations/translations/ja-JP.json | 6 +- .../translations/translations/zh-CN.json | 6 +- .../test/functional/apps/transform/cloning.ts | 26 ++++++ .../apps/transform/creation_index_pattern.ts | 40 +++++++++ .../test/functional/apps/transform/editing.ts | 13 +++ .../test/functional/apps/transform/index.ts | 1 + .../functional/services/transform/wizard.ts | 83 +++++++++++++++++++ 24 files changed, 461 insertions(+), 92 deletions(-) create mode 100644 x-pack/packages/ml/agg_utils/src/validate_number.test.ts create mode 100644 x-pack/packages/ml/agg_utils/src/validate_number.ts diff --git a/x-pack/packages/ml/agg_utils/src/index.ts b/x-pack/packages/ml/agg_utils/src/index.ts index 6705a28579b405..80283c975c66d0 100644 --- a/x-pack/packages/ml/agg_utils/src/index.ts +++ b/x-pack/packages/ml/agg_utils/src/index.ts @@ -8,3 +8,5 @@ export { buildSamplerAggregation } from './build_sampler_aggregation'; export { getAggIntervals } from './get_agg_intervals'; export { getSamplerAggregationsResponsePath } from './get_sampler_aggregations_response_path'; +export type { NumberValidationResult } from './validate_number'; +export { numberValidator } from './validate_number'; diff --git a/x-pack/packages/ml/agg_utils/src/validate_number.test.ts b/x-pack/packages/ml/agg_utils/src/validate_number.test.ts new file mode 100644 index 00000000000000..e40bc604f417f6 --- /dev/null +++ b/x-pack/packages/ml/agg_utils/src/validate_number.test.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { numberValidator } from '@kbn/ml-agg-utils'; + +describe('numberValidator', () => { + it('should only allow integers above zero', () => { + const integerOnlyValidator = numberValidator({ min: 1, integerOnly: true }); + // invalid + expect(integerOnlyValidator(0)).toMatchObject({ min: true }); + expect(integerOnlyValidator(0.1)).toMatchObject({ integerOnly: true }); + + // valid + expect(integerOnlyValidator(1)).toStrictEqual(null); + expect(integerOnlyValidator(100)).toStrictEqual(null); + }); + + it('should not allow value greater than max', () => { + const integerOnlyValidator = numberValidator({ min: 1, max: 8, integerOnly: true }); + // invalid + expect(integerOnlyValidator(10)).toMatchObject({ max: true }); + expect(integerOnlyValidator(11.1)).toMatchObject({ integerOnly: true, max: true }); + + // valid + expect(integerOnlyValidator(6)).toStrictEqual(null); + }); + + it('should allow non-integers', () => { + const integerOnlyValidator = numberValidator({ min: 1, max: 8, integerOnly: false }); + // invalid + expect(integerOnlyValidator(10)).toMatchObject({ max: true }); + expect(integerOnlyValidator(11.1)).toMatchObject({ max: true }); + + // valid + expect(integerOnlyValidator(6)).toStrictEqual(null); + expect(integerOnlyValidator(6.6)).toStrictEqual(null); + }); +}); diff --git a/x-pack/packages/ml/agg_utils/src/validate_number.ts b/x-pack/packages/ml/agg_utils/src/validate_number.ts new file mode 100644 index 00000000000000..5acfca3fd02346 --- /dev/null +++ b/x-pack/packages/ml/agg_utils/src/validate_number.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isPopulatedObject } from '@kbn/ml-is-populated-object'; + +export interface NumberValidationResult { + min: boolean; + max: boolean; + integerOnly: boolean; +} + +/** + * Validate if a number is greater than a specified minimum & lesser than specified maximum + */ +export function numberValidator(conditions?: { + min?: number; + max?: number; + integerOnly?: boolean; +}) { + if ( + conditions?.min !== undefined && + conditions.max !== undefined && + conditions.min > conditions.max + ) { + throw new Error('Invalid validator conditions'); + } + + return (value: number): NumberValidationResult | null => { + const result = {} as NumberValidationResult; + if (conditions?.min !== undefined && value < conditions.min) { + result.min = true; + } + if (conditions?.max !== undefined && value > conditions.max) { + result.max = true; + } + if (!!conditions?.integerOnly && !Number.isInteger(value)) { + result.integerOnly = true; + } + if (isPopulatedObject(result)) { + return result; + } + return null; + }; +} diff --git a/x-pack/plugins/ml/common/util/validators.ts b/x-pack/plugins/ml/common/util/validators.ts index b11edfd7406b8a..4cbef8470cfc0a 100644 --- a/x-pack/plugins/ml/common/util/validators.ts +++ b/x-pack/plugins/ml/common/util/validators.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { isPopulatedObject } from '@kbn/ml-is-populated-object'; import { ALLOWED_DATA_UNITS } from '../constants/validation'; import { parseInterval } from './parse_interval'; @@ -100,40 +99,3 @@ export function timeIntervalInputValidator() { return null; }; } - -export interface NumberValidationResult { - min: boolean; - max: boolean; - integerOnly: boolean; -} - -export function numberValidator(conditions?: { - min?: number; - max?: number; - integerOnly?: boolean; -}) { - if ( - conditions?.min !== undefined && - conditions.max !== undefined && - conditions.min > conditions.max - ) { - throw new Error('Invalid validator conditions'); - } - - return (value: number): NumberValidationResult | null => { - const result = {} as NumberValidationResult; - if (conditions?.min !== undefined && value < conditions.min) { - result.min = true; - } - if (conditions?.max !== undefined && value > conditions.max) { - result.max = true; - } - if (!!conditions?.integerOnly && !Number.isInteger(value)) { - result.integerOnly = true; - } - if (isPopulatedObject(result)) { - return result; - } - return null; - }; -} diff --git a/x-pack/plugins/ml/public/alerting/validators.ts b/x-pack/plugins/ml/public/alerting/validators.ts index 0c76e049b6da91..268f9409684241 100644 --- a/x-pack/plugins/ml/public/alerting/validators.ts +++ b/x-pack/plugins/ml/public/alerting/validators.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { numberValidator, timeIntervalInputValidator } from '../../common/util/validators'; - +import { numberValidator } from '@kbn/ml-agg-utils'; +import { timeIntervalInputValidator } from '../../common/util/validators'; export const validateLookbackInterval = timeIntervalInputValidator(); export const validateTopNBucket = numberValidator({ min: 1 }); diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/start_deployment_setup.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/start_deployment_setup.tsx index a783687b42402e..567e62f3772aa6 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/start_deployment_setup.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/start_deployment_setup.tsx @@ -32,12 +32,9 @@ import { toMountPoint, wrapWithTheme } from '@kbn/kibana-react-plugin/public'; import type { Observable } from 'rxjs'; import type { CoreTheme, OverlayStart } from '@kbn/core/public'; import { css } from '@emotion/react'; +import { numberValidator } from '@kbn/ml-agg-utils'; import { isCloud } from '../../services/ml_server_info'; -import { - composeValidators, - numberValidator, - requiredValidator, -} from '../../../../common/util/validators'; +import { composeValidators, requiredValidator } from '../../../../common/util/validators'; interface StartDeploymentSetup { config: ThreadingParams; diff --git a/x-pack/plugins/transform/common/api_schemas/transforms.ts b/x-pack/plugins/transform/common/api_schemas/transforms.ts index 70d6a250eb3860..9d74a7a37fb730 100644 --- a/x-pack/plugins/transform/common/api_schemas/transforms.ts +++ b/x-pack/plugins/transform/common/api_schemas/transforms.ts @@ -70,6 +70,8 @@ export const settingsSchema = schema.object({ max_page_search_size: schema.maybe(schema.nullable(schema.number())), // The default value is null, which disables throttling. docs_per_second: schema.maybe(schema.nullable(schema.number())), + // Optional value that takes precedence over cluster's setting. + num_failure_retries: schema.maybe(schema.nullable(schema.number())), }); export const sourceSchema = schema.object({ diff --git a/x-pack/plugins/transform/common/types/transform.ts b/x-pack/plugins/transform/common/types/transform.ts index f4f9437e05d134..8c6690641873a8 100644 --- a/x-pack/plugins/transform/common/types/transform.ts +++ b/x-pack/plugins/transform/common/types/transform.ts @@ -25,6 +25,7 @@ export type TransformBaseConfig = PutTransformsRequestSchema & { version?: string; alerting_rules?: TransformHealthAlertRule[]; _meta?: Record; + authorization?: object; }; export interface PivotConfigDefinition { diff --git a/x-pack/plugins/transform/public/app/common/request.test.ts b/x-pack/plugins/transform/public/app/common/request.test.ts index 99f060259497ab..2c4415c56c4664 100644 --- a/x-pack/plugins/transform/public/app/common/request.test.ts +++ b/x-pack/plugins/transform/public/app/common/request.test.ts @@ -311,6 +311,7 @@ describe('Transform: Common', () => { transformFrequency: '10m', transformSettingsMaxPageSearchSize: 100, transformSettingsDocsPerSecond: 400, + transformSettingsNumFailureRetries: 5, destinationIndex: 'the-destination-index', destinationIngestPipeline: 'the-destination-ingest-pipeline', touched: true, @@ -334,6 +335,7 @@ describe('Transform: Common', () => { settings: { max_page_search_size: 100, docs_per_second: 400, + num_failure_retries: 5, }, source: { index: ['the-data-view-title'], diff --git a/x-pack/plugins/transform/public/app/common/request.ts b/x-pack/plugins/transform/public/app/common/request.ts index 4700e42a3d946f..923f593024663d 100644 --- a/x-pack/plugins/transform/public/app/common/request.ts +++ b/x-pack/plugins/transform/public/app/common/request.ts @@ -206,6 +206,9 @@ export const getCreateTransformSettingsRequestBody = ( DEFAULT_TRANSFORM_SETTINGS_DOCS_PER_SECOND ? { docs_per_second: transformDetailsState.transformSettingsDocsPerSecond } : {}), + ...(typeof transformDetailsState.transformSettingsNumFailureRetries === 'number' + ? { num_failure_retries: transformDetailsState.transformSettingsNumFailureRetries } + : {}), }; return Object.keys(settings).length > 0 ? { settings } : {}; }; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/common.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/common.ts index f1cc84862a6ef9..d3fbbf8652370d 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/common.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/common.ts @@ -33,6 +33,7 @@ export interface StepDetailsExposedState { transformFrequency: string; transformSettingsMaxPageSearchSize: number; transformSettingsDocsPerSecond: number | null; + transformSettingsNumFailureRetries?: number; valid: boolean; dataViewTimeField?: string | undefined; _meta?: Record; @@ -52,6 +53,7 @@ export function getDefaultStepDetailsState(): StepDetailsExposedState { transformFrequency: DEFAULT_TRANSFORM_FREQUENCY, transformSettingsMaxPageSearchSize: DEFAULT_TRANSFORM_SETTINGS_MAX_PAGE_SEARCH_SIZE, transformSettingsDocsPerSecond: DEFAULT_TRANSFORM_SETTINGS_DOCS_PER_SECOND, + transformSettingsNumFailureRetries: undefined, destinationIndex: '', destinationIngestPipeline: '', touched: false, @@ -107,6 +109,9 @@ export function applyTransformConfigToDetailsState( } else { state.transformSettingsDocsPerSecond = null; } + if (typeof transformConfig.settings?.num_failure_retries === 'number') { + state.transformSettingsNumFailureRetries = transformConfig.settings.num_failure_retries; + } } if (transformConfig._meta) { diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx index b9ea4ea54a7f16..f24b8e941d9755 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx @@ -27,6 +27,7 @@ import { import { KBN_FIELD_TYPES } from '@kbn/data-plugin/common'; import { toMountPoint } from '@kbn/kibana-react-plugin/public'; +import { integerRangeMinus1To100Validator } from '../../../transform_management/components/edit_transform_flyout/use_edit_transform_flyout'; import { isEsIndices, isEsIngestPipelines, @@ -304,6 +305,14 @@ export const StepDetailsForm: FC = React.memo( transformSettingsMaxPageSearchSize ); + const [transformSettingsNumFailureRetries, setTransformSettingsNumFailureRetries] = useState< + string | number | undefined + >(defaults.transformSettingsNumFailureRetries); + const isTransformSettingsNumFailureRetriesValid = + transformSettingsNumFailureRetries === undefined || + transformSettingsNumFailureRetries === '-' || + integerRangeMinus1To100Validator(transformSettingsNumFailureRetries).length === 0; + const valid = !transformIdEmpty && transformIdValid && @@ -336,6 +345,13 @@ export const StepDetailsForm: FC = React.memo( transformFrequency, transformSettingsMaxPageSearchSize, transformSettingsDocsPerSecond, + transformSettingsNumFailureRetries: + transformSettingsNumFailureRetries === undefined || + transformSettingsNumFailureRetries === '' + ? undefined + : typeof transformSettingsNumFailureRetries === 'number' + ? transformSettingsNumFailureRetries + : parseInt(transformSettingsNumFailureRetries, 10), destinationIndex, destinationIngestPipeline, touched: true, @@ -357,6 +373,7 @@ export const StepDetailsForm: FC = React.memo( transformDescription, transformFrequency, transformSettingsMaxPageSearchSize, + transformSettingsNumFailureRetries, destinationIndex, destinationIngestPipeline, valid, @@ -840,6 +857,58 @@ export const StepDetailsForm: FC = React.memo( data-test-subj="transformMaxPageSearchSizeInput" /> + + = -1) + ? transformSettingsNumFailureRetries.toString() + : '' + } + onChange={(e) => { + if (e.target.value === '') { + setTransformSettingsNumFailureRetries(undefined); + return; + } + setTransformSettingsNumFailureRetries( + e.target.value === '-' ? '-' : parseInt(e.target.value, 10) + ); + }} + aria-label={i18n.translate( + 'xpack.transform.stepDetailsForm.numFailureRetriesAriaLabel', + { + defaultMessage: 'Choose a maximum number of retries.', + } + )} + isInvalid={!isTransformSettingsNumFailureRetriesValid} + data-test-subj="transformNumFailureRetriesInput" + /> + diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_summary.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_summary.tsx index c1ba1531106624..80203af34e105e 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_summary.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_summary.tsx @@ -25,6 +25,7 @@ export const StepDetailsSummary: FC = React.memo((props transformDescription, transformFrequency, transformSettingsMaxPageSearchSize, + transformSettingsNumFailureRetries, destinationIndex, destinationIngestPipeline, touched, @@ -153,6 +154,16 @@ export const StepDetailsSummary: FC = React.memo((props > {transformSettingsMaxPageSearchSize} + {typeof transformSettingsNumFailureRetries === 'number' ? ( + + {transformSettingsNumFailureRetries} + + ) : null} ); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx index ff517942101b87..41e628d495d499 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx @@ -323,7 +323,7 @@ export const EditTransformFlyoutForm: FC = ({ dataTestSubj="transformEditFlyoutDocsPerSecondInput" errorMessages={formFields.docsPerSecond.errorMessages} helpText={i18n.translate( - 'xpack.transform.transformList.editFlyoutFormDocsPerSecondHelptext', + 'xpack.transform.transformList.editFlyoutFormDocsPerSecondHelpText', { defaultMessage: 'To enable throttling, set a limit of documents to input per second.', @@ -343,7 +343,7 @@ export const EditTransformFlyoutForm: FC = ({ dataTestSubj="transformEditFlyoutMaxPageSearchSizeInput" errorMessages={formFields.maxPageSearchSize.errorMessages} helpText={i18n.translate( - 'xpack.transform.transformList.editFlyoutFormMaxPageSearchSizeHelptext', + 'xpack.transform.transformList.editFlyoutFormMaxPageSearchSizeHelpText', { defaultMessage: 'The initial page size to use for the composite aggregation for each checkpoint.', @@ -365,6 +365,22 @@ export const EditTransformFlyoutForm: FC = ({ } )} /> + dispatch({ field: 'numFailureRetries', value })} + value={formFields.numFailureRetries.value} + /> diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/use_edit_transform_flyout.ts b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/use_edit_transform_flyout.ts index a2dc9148bf03fb..523c36658919da 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/use_edit_transform_flyout.ts +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/use_edit_transform_flyout.ts @@ -7,6 +7,7 @@ import { isEqual } from 'lodash'; import { merge } from 'lodash'; +import { numberValidator } from '@kbn/ml-agg-utils'; import { useReducer } from 'react'; @@ -44,7 +45,8 @@ type EditTransformFormFields = | 'docsPerSecond' | 'maxPageSearchSize' | 'retentionPolicyField' - | 'retentionPolicyMaxAge'; + | 'retentionPolicyMaxAge' + | 'numFailureRetries'; type EditTransformFlyoutFieldsState = Record; @@ -107,16 +109,30 @@ type Validator = (value: any, isOptional?: boolean) => string[]; // We do this so we have fine grained control over field validation and the option to // cast to special values like `null` for disabling `docs_per_second`. const numberAboveZeroNotValidErrorMessage = i18n.translate( - 'xpack.transform.transformList.editFlyoutFormNumberNotValidErrorMessage', + 'xpack.transform.transformList.editFlyoutFormNumberAboveZeroNotValidErrorMessage', { defaultMessage: 'Value needs to be an integer above zero.', } ); + +const numberRangeMinus1To100NotValidErrorMessage = i18n.translate( + 'xpack.transform.transformList.editFlyoutFormNumberGreaterThanOrEqualToNegativeOneNotValidErrorMessage', + { + defaultMessage: 'Number of retries needs to be between 0 and 100, or -1 for infinite retries.', + } +); + export const integerAboveZeroValidator: Validator = (value) => - !isNaN(value) && Number.isInteger(+value) && +value > 0 && !(value + '').includes('.') + !(value + '').includes('.') && numberValidator({ min: 1, integerOnly: true })(+value) === null ? [] : [numberAboveZeroNotValidErrorMessage]; +export const integerRangeMinus1To100Validator: Validator = (value) => + !(value + '').includes('.') && + numberValidator({ min: -1, max: 100, integerOnly: true })(+value) === null + ? [] + : [numberRangeMinus1To100NotValidErrorMessage]; + const numberRange10To10000NotValidErrorMessage = i18n.translate( 'xpack.transform.transformList.editFlyoutFormNumberRange10To10000NotValidErrorMessage', { @@ -124,7 +140,8 @@ const numberRange10To10000NotValidErrorMessage = i18n.translate( } ); export const integerRange10To10000Validator: Validator = (value) => - integerAboveZeroValidator(value).length === 0 && +value >= 10 && +value <= 10000 + !(value + '').includes('.') && + numberValidator({ min: 10, max: 100001, integerOnly: true })(+value) === null ? [] : [numberRange10To10000NotValidErrorMessage]; @@ -214,6 +231,7 @@ const validate = { string: stringValidator, frequency: frequencyValidator, integerAboveZero: integerAboveZeroValidator, + integerRangeMinus1To100: integerRangeMinus1To100Validator, integerRange10To10000: integerRange10To10000Validator, retentionPolicyMaxAge: retentionPolicyMaxAgeValidator, } as const; @@ -407,6 +425,18 @@ export const getDefaultState = (config: TransformConfigUnion): EditTransformFlyo valueParser: (v) => +v, } ), + numFailureRetries: initializeField( + 'numFailureRetries', + 'settings.num_failure_retries', + config, + { + defaultValue: undefined, + isNullable: true, + isOptional: true, + validator: 'integerRangeMinus1To100', + valueParser: (v) => +v, + } + ), // retention_policy.* retentionPolicyField: initializeField( diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx index 6477e33a5c5a72..d96911cfdaa718 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { FC } from 'react'; +import React, { FC, useMemo } from 'react'; import { EuiButtonEmpty, EuiTabbedContent } from '@elastic/eui'; import { Optional } from '@kbn/utility-types'; @@ -13,6 +13,7 @@ import { i18n } from '@kbn/i18n'; import { stringHash } from '@kbn/ml-string-hash'; import moment from 'moment-timezone'; +import { isDefined } from '../../../../../../common/types/common'; import { TransformListRow } from '../../../../common'; import { useAppDependencies } from '../../../../app_dependencies'; import { ExpandedRowDetailsPane, SectionConfig, SectionItem } from './expanded_row_details_pane'; @@ -70,37 +71,52 @@ export const ExpandedRow: FC = ({ item, onAlertEdit }) => { position: 'right', }; - const configItems: Item[] = [ - { - title: 'transform_id', - description: item.id, - }, - { - title: 'transform_version', - description: item.config.version, - }, - { - title: 'description', - description: item.config.description ?? '', - }, - { - title: 'create_time', - description: - formatHumanReadableDateTimeSeconds(moment(item.config.create_time).unix() * 1000) ?? '', - }, - { - title: 'source_index', - description: Array.isArray(item.config.source.index) - ? item.config.source.index[0] - : item.config.source.index, - }, - { - title: 'destination_index', - description: Array.isArray(item.config.dest.index) - ? item.config.dest.index[0] - : item.config.dest.index, - }, - ]; + const configItems = useMemo(() => { + const configs: Item[] = [ + { + title: 'transform_id', + description: item.id, + }, + { + title: 'transform_version', + description: item.config.version, + }, + { + title: 'description', + description: item.config.description ?? '', + }, + { + title: 'create_time', + description: + formatHumanReadableDateTimeSeconds(moment(item.config.create_time).unix() * 1000) ?? '', + }, + { + title: 'source_index', + description: Array.isArray(item.config.source.index) + ? item.config.source.index[0] + : item.config.source.index, + }, + { + title: 'destination_index', + description: Array.isArray(item.config.dest.index) + ? item.config.dest.index[0] + : item.config.dest.index, + }, + { + title: 'authorization', + description: item.config.authorization ? JSON.stringify(item.config.authorization) : '', + }, + ]; + if (isDefined(item.config.settings?.num_failure_retries)) { + configs.push({ + title: 'num_failure_retries', + description: item.config.settings?.num_failure_retries ?? '', + }); + } + return configs; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [item?.config]); const general: SectionConfig = { title: 'General', diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 495adf3af6990e..f40f5321c77a2b 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -30342,16 +30342,16 @@ "xpack.transform.transformList.editFlyoutFormDestinationButtonContent": "Configuration de destination", "xpack.transform.transformList.editFlyoutFormDestinationIndexLabel": "Index de destination", "xpack.transform.transformList.editFlyoutFormDestinationIngestPipelineLabel": "Pipeline d'ingestion", - "xpack.transform.transformList.editFlyoutFormDocsPerSecondHelptext": "Pour activer la régulation, définissez une limite de documents à saisir par seconde.", + "xpack.transform.transformList.editFlyoutFormDocsPerSecondHelpText": "Pour activer la régulation, définissez une limite de documents à saisir par seconde.", "xpack.transform.transformList.editFlyoutFormDocsPerSecondLabel": "Documents par seconde", "xpack.transform.transformList.editFlyoutFormFrequencyHelpText": "Intervalle entre les vérifications de modifications dans les index source lorsque la transformation est exécutée en continu. Détermine également l'intervalle des nouvelles tentatives en cas d'échecs temporaires lorsque la transformation effectue une recherche ou une indexation. La valeur minimale est de 1 s et la valeur maximale de 1 h.", "xpack.transform.transformList.editFlyoutFormFrequencyLabel": "Fréquence", "xpack.transform.transformList.editFlyoutFormFrequencyNotValidErrorMessage": "La valeur de fréquence n'est pas valide.", "xpack.transform.transformList.editFlyoutFormFrequencyPlaceholderText": "Par défaut : {defaultValue}", - "xpack.transform.transformList.editFlyoutFormMaxPageSearchSizeHelptext": "Définit la taille de pages initiale à utiliser pour l'agrégation imbriquée de chaque point de contrôle.", + "xpack.transform.transformList.editFlyoutFormMaxPageSearchSizeHelpText": "Définit la taille de pages initiale à utiliser pour l'agrégation imbriquée de chaque point de contrôle.", "xpack.transform.transformList.editFlyoutFormMaxPageSearchSizeLabel": "Taille maximale de recherche de pages", "xpack.transform.transformList.editFlyoutFormMaxPageSearchSizePlaceholderText": "Par défaut : {defaultValue}", - "xpack.transform.transformList.editFlyoutFormNumberNotValidErrorMessage": "La valeur doit être un entier supérieur à zéro.", + "xpack.transform.transformList.editFlyoutFormNumberAboveZeroNotValidErrorMessage": "La valeur doit être un entier supérieur à zéro.", "xpack.transform.transformList.editFlyoutFormNumberRange10To10000NotValidErrorMessage": "La valeur doit être un entier compris entre 10 et 10 000.", "xpack.transform.transformList.editFlyoutFormRequiredErrorMessage": "Champs requis.", "xpack.transform.transformList.editFlyoutFormRetentionMaxAgeFieldLabel": "Âge maximal", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 335cecfc90f017..1957a72941ba15 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -30326,16 +30326,16 @@ "xpack.transform.transformList.editFlyoutFormDestinationButtonContent": "ディスティネーション構成", "xpack.transform.transformList.editFlyoutFormDestinationIndexLabel": "デスティネーションインデックス", "xpack.transform.transformList.editFlyoutFormDestinationIngestPipelineLabel": "インジェストパイプライン", - "xpack.transform.transformList.editFlyoutFormDocsPerSecondHelptext": "スロットリングを有効にするには、毎秒入力するドキュメントの上限を設定します。", + "xpack.transform.transformList.editFlyoutFormDocsPerSecondHelpText": "スロットリングを有効にするには、毎秒入力するドキュメントの上限を設定します。", "xpack.transform.transformList.editFlyoutFormDocsPerSecondLabel": "毎秒あたりのドキュメント", "xpack.transform.transformList.editFlyoutFormFrequencyHelpText": "変換が連続実行されているときにソースインデックスで変更を確認する間の間隔。また、変換が検索またはインデックス中に一時障害が発生した場合に、再試行する間隔も決定します。最小値は1秒で、最大値は1時間です。", "xpack.transform.transformList.editFlyoutFormFrequencyLabel": "頻度", "xpack.transform.transformList.editFlyoutFormFrequencyNotValidErrorMessage": "頻度値が無効です。", "xpack.transform.transformList.editFlyoutFormFrequencyPlaceholderText": "デフォルト:{defaultValue}", - "xpack.transform.transformList.editFlyoutFormMaxPageSearchSizeHelptext": "各チェックポイントの複合集計で使用する、初期ページサイズを定義します。", + "xpack.transform.transformList.editFlyoutFormMaxPageSearchSizeHelpText": "各チェックポイントの複合集計で使用する、初期ページサイズを定義します。", "xpack.transform.transformList.editFlyoutFormMaxPageSearchSizeLabel": "最大ページ検索サイズ", "xpack.transform.transformList.editFlyoutFormMaxPageSearchSizePlaceholderText": "デフォルト:{defaultValue}", - "xpack.transform.transformList.editFlyoutFormNumberNotValidErrorMessage": "値は1以上の整数でなければなりません。", + "xpack.transform.transformList.editFlyoutFormNumberAboveZeroNotValidErrorMessage": "値は1以上の整数でなければなりません。", "xpack.transform.transformList.editFlyoutFormNumberRange10To10000NotValidErrorMessage": "値は10~10000の範囲の整数でなければなりません。", "xpack.transform.transformList.editFlyoutFormRequiredErrorMessage": "必須フィールド。", "xpack.transform.transformList.editFlyoutFormRetentionMaxAgeFieldLabel": "最大年齢", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index a68b3f001a1153..d765633ad18b7d 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -30354,16 +30354,16 @@ "xpack.transform.transformList.editFlyoutFormDestinationButtonContent": "目标配置", "xpack.transform.transformList.editFlyoutFormDestinationIndexLabel": "目标索引", "xpack.transform.transformList.editFlyoutFormDestinationIngestPipelineLabel": "采集管道", - "xpack.transform.transformList.editFlyoutFormDocsPerSecondHelptext": "要启用节流,请设置每秒要输入的文档限值。", + "xpack.transform.transformList.editFlyoutFormDocsPerSecondHelpText": "要启用节流,请设置每秒要输入的文档限值。", "xpack.transform.transformList.editFlyoutFormDocsPerSecondLabel": "每秒文档数", "xpack.transform.transformList.editFlyoutFormFrequencyHelpText": "在转换不间断地执行时检查源索引更改的时间间隔。还确定在转换搜索或索引时发生暂时失败时的重试时间间隔。最小值为 1 秒,最大值为 1 小时。", "xpack.transform.transformList.editFlyoutFormFrequencyLabel": "频率", "xpack.transform.transformList.editFlyoutFormFrequencyNotValidErrorMessage": "频率值无效。", "xpack.transform.transformList.editFlyoutFormFrequencyPlaceholderText": "默认值:{defaultValue}", - "xpack.transform.transformList.editFlyoutFormMaxPageSearchSizeHelptext": "定义用于每个检查点的组合聚合的初始页面大小。", + "xpack.transform.transformList.editFlyoutFormMaxPageSearchSizeHelpText": "定义用于每个检查点的组合聚合的初始页面大小。", "xpack.transform.transformList.editFlyoutFormMaxPageSearchSizeLabel": "最大页面搜索大小", "xpack.transform.transformList.editFlyoutFormMaxPageSearchSizePlaceholderText": "默认值:{defaultValue}", - "xpack.transform.transformList.editFlyoutFormNumberNotValidErrorMessage": "值必须是大于零的整数。", + "xpack.transform.transformList.editFlyoutFormNumberAboveZeroNotValidErrorMessage": "值必须是大于零的整数。", "xpack.transform.transformList.editFlyoutFormNumberRange10To10000NotValidErrorMessage": "值必须是介于 10 到 10000 之间的整数。", "xpack.transform.transformList.editFlyoutFormRequiredErrorMessage": "必填字段。", "xpack.transform.transformList.editFlyoutFormRetentionMaxAgeFieldLabel": "最大存在时间", diff --git a/x-pack/test/functional/apps/transform/cloning.ts b/x-pack/test/functional/apps/transform/cloning.ts index fd414ae5d53654..46f398b7842c97 100644 --- a/x-pack/test/functional/apps/transform/cloning.ts +++ b/x-pack/test/functional/apps/transform/cloning.ts @@ -23,6 +23,11 @@ interface TestData { expected: any; } +function getNumFailureRetriesStr(value: number | null | undefined) { + if (value === null || value === undefined) return ''; + return value.toString(); +} + function getTransformConfig(): TransformPivotConfig { const date = Date.now(); return { @@ -38,6 +43,7 @@ function getTransformConfig(): TransformPivotConfig { retention_policy: { time: { field: 'order_date', max_age: '1d' } }, settings: { max_page_search_size: 250, + num_failure_retries: 0, }, dest: { index: `user-ec_2_${date}` }, }; @@ -76,6 +82,7 @@ function getTransformConfigWithRuntimeMappings(): TransformPivotConfig { retention_policy: { time: { field: 'order_date', max_age: '3d' } }, settings: { max_page_search_size: 250, + num_failure_retries: 5, }, dest: { index: `user-ec_2_${date}` }, }; @@ -161,6 +168,9 @@ export default function ({ getService }: FtrProviderContext) { retentionPolicySwitchEnabled: true, retentionPolicyField: 'order_date', retentionPolicyMaxAge: '1d', + numFailureRetries: getNumFailureRetriesStr( + transformConfigWithPivot.settings?.num_failure_retries + ), }, }, { @@ -193,6 +203,9 @@ export default function ({ getService }: FtrProviderContext) { retentionPolicySwitchEnabled: true, retentionPolicyField: 'order_date', retentionPolicyMaxAge: '3d', + numFailureRetries: getNumFailureRetriesStr( + transformConfigWithRuntimeMapping.settings?.num_failure_retries + ), }, }, { @@ -366,10 +379,23 @@ export default function ({ getService }: FtrProviderContext) { await transform.wizard.assertTransformMaxPageSearchSizeValue( testData.originalConfig.settings!.max_page_search_size! ); + if (testData.expected.numFailureRetries !== undefined) { + await transform.wizard.assertNumFailureRetriesValue( + testData.expected.numFailureRetries + ); + } await transform.testExecution.logTestStep('should load the create step'); await transform.wizard.advanceToCreateStep(); + if (testData.expected.numFailureRetries !== undefined) { + await transform.testExecution.logTestStep('displays the summary details'); + await transform.wizard.openTransformAdvancedSettingsSummaryAccordion(); + await transform.wizard.assertTransformNumFailureRetriesSummaryValue( + testData.expected.numFailureRetries + ); + } + await transform.testExecution.logTestStep('should display the create and start button'); await transform.wizard.assertCreateAndStartButtonExists(); await transform.wizard.assertCreateAndStartButtonEnabled(true); diff --git a/x-pack/test/functional/apps/transform/creation_index_pattern.ts b/x-pack/test/functional/apps/transform/creation_index_pattern.ts index 297ae51e42e357..93368c7d635eac 100644 --- a/x-pack/test/functional/apps/transform/creation_index_pattern.ts +++ b/x-pack/test/functional/apps/transform/creation_index_pattern.ts @@ -35,6 +35,7 @@ export default function ({ getService }: FtrProviderContext) { await transform.testResources.deleteIndexPatternByTitle('ft_ecommerce'); }); + const DEFAULT_NUM_FAILURE_RETRIES = '5'; const testDataList: Array = [ { type: 'pivot', @@ -92,6 +93,7 @@ export default function ({ getService }: FtrProviderContext) { return `user-${this.transformId}`; }, discoverAdjustSuperDatePicker: true, + numFailureRetries: '7', expected: { pivotAdvancedEditorValueArr: ['{', ' "group_by": {', ' "category": {'], pivotAdvancedEditorValue: { @@ -250,6 +252,7 @@ export default function ({ getService }: FtrProviderContext) { }, ], discoverQueryHits: '7,270', + numFailureRetries: '7', }, } as PivotTransformTestData, { @@ -288,6 +291,7 @@ export default function ({ getService }: FtrProviderContext) { return `user-${this.transformId}`; }, discoverAdjustSuperDatePicker: false, + numFailureRetries: '-1', expected: { pivotAdvancedEditorValueArr: ['{', ' "group_by": {', ' "geoip.country_iso_code": {'], pivotAdvancedEditorValue: { @@ -335,6 +339,7 @@ export default function ({ getService }: FtrProviderContext) { rows: 5, }, discoverQueryHits: '10', + numFailureRetries: '-1', }, } as PivotTransformTestData, { @@ -360,6 +365,7 @@ export default function ({ getService }: FtrProviderContext) { return `user-${this.transformId}`; }, discoverAdjustSuperDatePicker: false, + numFailureRetries: '0', expected: { pivotAdvancedEditorValueArr: ['{', ' "group_by": {', ' "customer_gender": {'], pivotAdvancedEditorValue: { @@ -393,6 +399,7 @@ export default function ({ getService }: FtrProviderContext) { rows: 5, }, discoverQueryHits: '2', + numFailureRetries: '0', }, } as PivotTransformTestData, { @@ -418,6 +425,7 @@ export default function ({ getService }: FtrProviderContext) { }, destinationDataViewTimeField: 'order_date', discoverAdjustSuperDatePicker: true, + numFailureRetries: '101', expected: { latestPreview: { column: 0, @@ -443,6 +451,7 @@ export default function ({ getService }: FtrProviderContext) { ], }, discoverQueryHits: '10', + numFailureRetries: 'error', }, } as LatestTransformTestData, ]; @@ -602,9 +611,40 @@ export default function ({ getService }: FtrProviderContext) { await transform.wizard.assertContinuousModeSwitchExists(); await transform.wizard.assertContinuousModeSwitchCheckState(false); + await transform.testExecution.logTestStep( + 'should display the advanced settings and show pre-filled configuration' + ); + await transform.wizard.openTransformAdvancedSettingsAccordion(); + if ( + testData.numFailureRetries !== undefined && + testData.expected.numFailureRetries !== undefined + ) { + await transform.wizard.assertNumFailureRetriesValue(''); + await transform.wizard.setTransformNumFailureRetriesValue( + testData.numFailureRetries.toString(), + testData.expected.numFailureRetries + ); + // If num failure input is expected to give an error, sets it back to a valid + // so that we can continue creating the transform + if (testData.expected.numFailureRetries === 'error') { + await transform.wizard.setTransformNumFailureRetriesValue( + DEFAULT_NUM_FAILURE_RETRIES, + DEFAULT_NUM_FAILURE_RETRIES + ); + } + } + await transform.testExecution.logTestStep('loads the create step'); await transform.wizard.advanceToCreateStep(); + await transform.testExecution.logTestStep('displays the summary details'); + await transform.wizard.openTransformAdvancedSettingsSummaryAccordion(); + await transform.wizard.assertTransformNumFailureRetriesSummaryValue( + testData.expected.numFailureRetries === 'error' + ? DEFAULT_NUM_FAILURE_RETRIES + : testData.expected.numFailureRetries + ); + await transform.testExecution.logTestStep('displays the create and start button'); await transform.wizard.assertCreateAndStartButtonExists(); await transform.wizard.assertCreateAndStartButtonEnabled(true); diff --git a/x-pack/test/functional/apps/transform/editing.ts b/x-pack/test/functional/apps/transform/editing.ts index 62feab8864e05c..f96052ab28e186 100644 --- a/x-pack/test/functional/apps/transform/editing.ts +++ b/x-pack/test/functional/apps/transform/editing.ts @@ -61,6 +61,7 @@ export default function ({ getService }: FtrProviderContext) { resetRetentionPolicy: false, transformRetentionPolicyField: 'order_date', transformRetentionPolicyMaxAge: '1d', + numFailureRetries: '0', expected: { messageText: 'updated transform.', retentionPolicy: { @@ -82,6 +83,7 @@ export default function ({ getService }: FtrProviderContext) { transformDocsPerSecond: '1000', transformFrequency: '10m', resetRetentionPolicy: true, + numFailureRetries: '7', expected: { messageText: 'updated transform.', retentionPolicy: { @@ -145,6 +147,17 @@ export default function ({ getService }: FtrProviderContext) { testData.transformDocsPerSecond ); + await transform.testExecution.logTestStep( + 'should update the transform number of failure retries' + ); + await transform.editFlyout.openTransformEditAccordionAdvancedSettings(); + await transform.editFlyout.assertTransformEditFlyoutInputExists('NumFailureRetries'); + await transform.editFlyout.assertTransformEditFlyoutInputValue('NumFailureRetries', ''); + await transform.editFlyout.setTransformEditFlyoutInputValue( + 'NumFailureRetries', + testData.numFailureRetries + ); + await transform.testExecution.logTestStep('should update the transform frequency'); await transform.editFlyout.assertTransformEditFlyoutInputExists('Frequency'); await transform.editFlyout.assertTransformEditFlyoutInputValue( diff --git a/x-pack/test/functional/apps/transform/index.ts b/x-pack/test/functional/apps/transform/index.ts index 0c5227ae2f472c..e87f72ed98880c 100644 --- a/x-pack/test/functional/apps/transform/index.ts +++ b/x-pack/test/functional/apps/transform/index.ts @@ -67,6 +67,7 @@ export interface BaseTransformTestData { destinationIndex: string; destinationDataViewTimeField?: string; discoverAdjustSuperDatePicker: boolean; + numFailureRetries?: string; } export interface PivotTransformTestData extends BaseTransformTestData { diff --git a/x-pack/test/functional/services/transform/wizard.ts b/x-pack/test/functional/services/transform/wizard.ts index bc281204008958..621062ad7d115f 100644 --- a/x-pack/test/functional/services/transform/wizard.ts +++ b/x-pack/test/functional/services/transform/wizard.ts @@ -24,6 +24,7 @@ export function TransformWizardProvider({ getService, getPageObjects }: FtrProvi const testSubjects = getService('testSubjects'); const comboBox = getService('comboBox'); const retry = getService('retry'); + const find = getService('find'); const ml = getService('ml'); const PageObjects = getPageObjects(['discover', 'timePicker', 'unifiedSearch']); @@ -798,6 +799,72 @@ export function TransformWizardProvider({ getService, getPageObjects }: FtrProvi ); }, + async assertTransformNumFailureRetriesInputExists() { + await testSubjects.existOrFail('transformNumFailureRetriesInput'); + expect(await testSubjects.isDisplayed('transformNumFailureRetriesInput')).to.eql( + true, + `Expected 'Number of retries failure' input to be displayed` + ); + }, + + async assertNumFailureRetriesValue(expectedValue: string) { + await this.assertTransformNumFailureRetriesInputExists(); + const actualValue = await testSubjects.getAttribute( + 'transformNumFailureRetriesInput', + 'value' + ); + expect(actualValue).to.eql( + expectedValue, + `Transform num failure retries text should be '${expectedValue}' (got '${actualValue}')` + ); + }, + + async assertTransformNumFailureRetriesErrorMessageExists(expected: boolean) { + const row = await testSubjects.find('transformNumFailureRetriesFormRow'); + const errorElements = await row.findAllByClassName('euiFormErrorText'); + + if (expected) { + expect(errorElements.length).greaterThan( + 0, + 'Expected Transform num failure retries to display error message' + ); + } else { + expect(errorElements.length).eql( + 0, + 'Expected Transform num failure retries to not display error message' + ); + } + }, + + async setTransformNumFailureRetriesValue(value: string, expectedResult: 'error' | string) { + await retry.tryForTime(5000, async () => { + await testSubjects.setValue('transformNumFailureRetriesInput', value); + if (expectedResult !== 'error') { + await this.assertTransformNumFailureRetriesErrorMessageExists(false); + await this.assertNumFailureRetriesValue(expectedResult); + } else { + await this.assertTransformNumFailureRetriesErrorMessageExists(true); + } + }); + }, + + async assertTransformNumFailureRetriesSummaryValue(expectedValue: string) { + await retry.tryForTime(5000, async () => { + await testSubjects.existOrFail('transformWizardAdvancedSettingsNumFailureRetriesLabel'); + const row = await testSubjects.find( + 'transformWizardAdvancedSettingsNumFailureRetriesLabel' + ); + const actualValue = await ( + await row.findByClassName('euiFormRow__fieldWrapper') + ).getVisibleText(); + + expect(actualValue).to.eql( + expectedValue, + `Transform num failure retries summary value should be '${expectedValue}' (got '${actualValue}')` + ); + }); + }, + async assertTransformMaxPageSearchSizeInputExists() { await testSubjects.existOrFail('transformMaxPageSearchSizeInput'); expect(await testSubjects.isDisplayed('transformMaxPageSearchSizeInput')).to.eql( @@ -817,6 +884,22 @@ export function TransformWizardProvider({ getService, getPageObjects }: FtrProvi ); }, + async assertAccordionAdvancedSettingsSummaryAccordionExists() { + await testSubjects.existOrFail('transformWizardAccordionAdvancedSettingsSummary'); + }, + + // for now we expect this to be used only for opening the accordion + async openTransformAdvancedSettingsSummaryAccordion() { + await this.assertAccordionAdvancedSettingsSummaryAccordionExists(); + await find.clickByCssSelector( + '[aria-controls="transformWizardAccordionAdvancedSettingsSummary"]' + ); + + await testSubjects.existOrFail('transformWizardAdvancedSettingsFrequencyLabel'); + await testSubjects.existOrFail('transformWizardAdvancedSettingsMaxPageSearchSizeLabel'); + await testSubjects.existOrFail('transformWizardAdvancedSettingsNumFailureRetriesLabel'); + }, + async assertCreateAndStartButtonExists() { await testSubjects.existOrFail('transformWizardCreateAndStartButton'); expect(await testSubjects.isDisplayed('transformWizardCreateAndStartButton')).to.eql( From 9860e25a88833dc1e8d42c6464651e822213b43f Mon Sep 17 00:00:00 2001 From: Vitalii Dmyterko <92328789+vitaliidm@users.noreply.github.com> Date: Fri, 8 Jul 2022 16:19:21 +0100 Subject: [PATCH 15/47] [Security Solution][Detections] fixes rules management table selection after auto refresh (#135533) ## Summary - fixes https://github.com/elastic/kibana/issues/135297 - pauses rules table auto-refresh if any rule selected - removes pausing auto-refresh when performing bulk actions, as it not needed anymore - clear selection of all rules when bulk duplicate performed, as newly created rules can change existing selection ### Before https://user-images.githubusercontent.com/92328789/177141742-af2e7df5-9522-49af-ae20-563173632196.mov ### After https://user-images.githubusercontent.com/92328789/177143675-a6515dc7-a2b4-466f-80e0-7912f2f9f417.mov ### Checklist Delete any items that are not applicable to this PR. - [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 ### 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) ### Release note fixes losing selection of items on rules management table during auto-refresh --- .../detection_rules/prebuilt_rules.spec.ts | 1 - .../rules_table_auto_refresh.spec.ts | 126 ++++++++++++++++++ .../detection_rules/sorting.spec.ts | 21 --- .../cypress/screens/alerts_detection_rules.ts | 8 ++ .../cypress/tasks/alerts_detection_rules.ts | 35 +++++ .../utility_bar/utility_bar_section.tsx | 11 +- .../all/bulk_actions/use_bulk_actions.tsx | 23 +--- .../__mocks__/rules_table_context.tsx | 1 + .../all/rules_table/rules_table_context.tsx | 38 +++++- .../rules/all/utility_bar.test.tsx | 59 +++++--- .../rules/all/utility_bar.tsx | 22 ++- 11 files changed, 278 insertions(+), 67 deletions(-) create mode 100644 x-pack/plugins/security_solution/cypress/integration/detection_rules/rules_table_auto_refresh.spec.ts diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/prebuilt_rules.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/prebuilt_rules.spec.ts index 389b601f415e23..397162f69d490a 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/prebuilt_rules.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/prebuilt_rules.spec.ts @@ -92,7 +92,6 @@ describe('Prebuilt rules', () => { waitForRuleToChangeStatus(); cy.get(RULE_SWITCH).should('have.attr', 'aria-checked', 'true'); - selectAllRules(); disableSelectedRules(); waitForRuleToChangeStatus(); cy.get(RULE_SWITCH).should('have.attr', 'aria-checked', 'false'); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/rules_table_auto_refresh.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/rules_table_auto_refresh.spec.ts new file mode 100644 index 00000000000000..ca5ce2b2bfebd7 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/rules_table_auto_refresh.spec.ts @@ -0,0 +1,126 @@ +/* + * 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 { + RULE_CHECKBOX, + REFRESH_RULES_STATUS, + REFRESH_SETTINGS_SWITCH, + REFRESH_SETTINGS_SELECTION_NOTE, +} from '../../screens/alerts_detection_rules'; +import { + changeRowsPerPageTo, + checkAutoRefresh, + waitForRulesTableToBeLoaded, + selectAllRules, + openRefreshSettingsPopover, + clearAllRuleSelection, + selectNumberOfRules, + mockGlobalClock, + disableAutoRefresh, + checkAutoRefreshIsDisabled, + checkAutoRefreshIsEnabled, +} from '../../tasks/alerts_detection_rules'; +import { login, visit } from '../../tasks/login'; + +import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../urls/navigation'; +import { createCustomRule } from '../../tasks/api_calls/rules'; +import { cleanKibana } from '../../tasks/common'; +import { getNewRule } from '../../objects/rule'; + +const DEFAULT_RULE_REFRESH_INTERVAL_VALUE = 60000; + +describe('Alerts detection rules table auto-refresh', () => { + before(() => { + cleanKibana(); + login(); + for (let i = 1; i < 7; i += 1) { + createCustomRule({ ...getNewRule(), name: `Test rule ${i}` }, `${i}`); + } + }); + + it('Auto refreshes rules', () => { + visit(DETECTIONS_RULE_MANAGEMENT_URL); + + mockGlobalClock(); + waitForRulesTableToBeLoaded(); + + // ensure rules have rendered. As there is no user interaction in this test, + // rules were not rendered before test completes + cy.get(RULE_CHECKBOX).should('have.length', 6); + + // mock 1 minute passing to make sure refresh is conducted + checkAutoRefresh(DEFAULT_RULE_REFRESH_INTERVAL_VALUE, 'be.visible'); + + cy.contains(REFRESH_RULES_STATUS, 'Updated now'); + }); + + it('should prevent table from rules refetch if any rule selected', () => { + visit(DETECTIONS_RULE_MANAGEMENT_URL); + + mockGlobalClock(); + waitForRulesTableToBeLoaded(); + + selectNumberOfRules(1); + + // mock 1 minute passing to make sure refresh is not conducted + checkAutoRefresh(DEFAULT_RULE_REFRESH_INTERVAL_VALUE, 'not.exist'); + + // ensure rule is still selected + cy.get(RULE_CHECKBOX).first().should('be.checked'); + + cy.contains(REFRESH_RULES_STATUS, 'Updated 1 minute ago'); + }); + + it('should disable auto refresh when any rule selected and enable it after rules unselected', () => { + visit(DETECTIONS_RULE_MANAGEMENT_URL); + waitForRulesTableToBeLoaded(); + changeRowsPerPageTo(5); + + // check refresh settings if it's enabled before selecting + openRefreshSettingsPopover(); + checkAutoRefreshIsEnabled(); + + selectAllRules(); + + // auto refresh should be disabled after rules selected + openRefreshSettingsPopover(); + checkAutoRefreshIsDisabled(); + + // if any rule selected, refresh switch should be disabled and help note to users should displayed + cy.get(REFRESH_SETTINGS_SWITCH).should('be.disabled'); + cy.contains( + REFRESH_SETTINGS_SELECTION_NOTE, + 'Note: Refresh is disabled while there is an active selection.' + ); + + clearAllRuleSelection(); + + // after all rules unselected, auto refresh should renew + openRefreshSettingsPopover(); + checkAutoRefreshIsEnabled(); + }); + + it('should not enable auto refresh after rules were unselected if auto refresh was disabled', () => { + visit(DETECTIONS_RULE_MANAGEMENT_URL); + waitForRulesTableToBeLoaded(); + changeRowsPerPageTo(5); + + openRefreshSettingsPopover(); + disableAutoRefresh(); + + selectAllRules(); + + openRefreshSettingsPopover(); + checkAutoRefreshIsDisabled(); + + clearAllRuleSelection(); + + // after all rules unselected, auto refresh should still be disabled + openRefreshSettingsPopover(); + checkAutoRefreshIsDisabled(); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/sorting.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/sorting.spec.ts index c91af7e44cccb1..c5cacc511c6005 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/sorting.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/sorting.spec.ts @@ -18,7 +18,6 @@ import { import { enableRule, changeRowsPerPageTo, - checkAutoRefresh, goToPage, sortByEnabledRules, waitForRulesTableToBeLoaded, @@ -36,8 +35,6 @@ import { getNewThresholdRule, } from '../../objects/rule'; -const DEFAULT_RULE_REFRESH_INTERVAL_VALUE = 60000; - describe('Alerts detection rules', () => { before(() => { cleanKibana(); @@ -100,22 +97,4 @@ describe('Alerts detection rules', () => { .find(SECOND_PAGE_SELECTOR) .should('have.class', 'euiPaginationButton-isActive'); }); - - it('Auto refreshes rules', () => { - /** - * Ran into the error: timer created with setInterval() but cleared with cancelAnimationFrame() - * There are no cancelAnimationFrames in the codebase that are used to clear a setInterval so - * explicitly set the below overrides. see https://docs.cypress.io/api/commands/clock#Function-names - */ - - visit(DETECTIONS_RULE_MANAGEMENT_URL); - - cy.clock(Date.now(), ['setInterval', 'clearInterval', 'Date']); - - waitForRulesTableToBeLoaded(); - - // mock 1 minute passing to make sure refresh - // is conducted - checkAutoRefresh(DEFAULT_RULE_REFRESH_INTERVAL_VALUE, 'be.visible'); - }); }); diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts index 2e186d95f176c8..096e64cae45d27 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts @@ -130,3 +130,11 @@ export const RULES_TAGS_POPOVER_WRAPPER = '[data-test-subj="tagsDisplayPopoverWr export const RULES_TAGS_FILTER_BTN = '[data-test-subj="tags-filter-popover-button"]'; export const SELECTED_RULES_NUMBER_LABEL = '[data-test-subj="selectedRules"]'; + +export const REFRESH_SETTINGS_POPOVER = '[data-test-subj="refreshSettings-popover"]'; + +export const REFRESH_SETTINGS_SWITCH = '[data-test-subj="refreshSettingsSwitch"]'; + +export const REFRESH_SETTINGS_SELECTION_NOTE = '[data-test-subj="refreshSettingsSelectionNote"]'; + +export const REFRESH_RULES_STATUS = '[data-test-subj="refreshRulesStatus"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts index 80d5bf54ca6c4c..75c44f5e91e545 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts @@ -48,6 +48,8 @@ import { RULES_TAGS_POPOVER_WRAPPER, INTEGRATIONS_POPOVER, SELECTED_RULES_NUMBER_LABEL, + REFRESH_SETTINGS_POPOVER, + REFRESH_SETTINGS_SWITCH, } from '../screens/alerts_detection_rules'; import { ALL_ACTIONS } from '../screens/rule_details'; import { LOADING_INDICATOR } from '../screens/security_header'; @@ -195,6 +197,11 @@ export const selectAllRules = () => { cy.get(SELECT_ALL_RULES_BTN).contains('Clear'); }; +export const clearAllRuleSelection = () => { + cy.get(SELECT_ALL_RULES_BTN).contains('Clear').click(); + cy.get(SELECT_ALL_RULES_BTN).contains('Select all'); +}; + export const confirmRulesDelete = () => { cy.get(RULES_DELETE_CONFIRMATION_MODAL).should('be.visible'); cy.get(MODAL_CONFIRMATION_BTN).click(); @@ -297,3 +304,31 @@ export const testAllTagsBadges = (tags: string[]) => { export const testMultipleSelectedRulesLabel = (rulesCount: number) => { cy.get(SELECTED_RULES_NUMBER_LABEL).should('have.text', `Selected ${rulesCount} rules`); }; + +export const openRefreshSettingsPopover = () => { + cy.get(REFRESH_SETTINGS_POPOVER).click(); + cy.get(REFRESH_SETTINGS_SWITCH).should('be.visible'); +}; + +export const checkAutoRefreshIsDisabled = () => { + cy.get(REFRESH_SETTINGS_SWITCH).should('have.attr', 'aria-checked', 'false'); +}; + +export const checkAutoRefreshIsEnabled = () => { + cy.get(REFRESH_SETTINGS_SWITCH).should('have.attr', 'aria-checked', 'true'); +}; + +export const disableAutoRefresh = () => { + cy.get(REFRESH_SETTINGS_SWITCH).click(); + checkAutoRefreshIsDisabled(); +}; + +export const mockGlobalClock = () => { + /** + * Ran into the error: timer created with setInterval() but cleared with cancelAnimationFrame() + * There are no cancelAnimationFrames in the codebase that are used to clear a setInterval so + * explicitly set the below overrides. see https://docs.cypress.io/api/commands/clock#Function-names + */ + + cy.clock(Date.now(), ['setInterval', 'clearInterval', 'Date']); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/utility_bar/utility_bar_section.tsx b/x-pack/plugins/security_solution/public/common/components/utility_bar/utility_bar_section.tsx index c84219cc634888..dc966516c83736 100644 --- a/x-pack/plugins/security_solution/public/common/components/utility_bar/utility_bar_section.tsx +++ b/x-pack/plugins/security_solution/public/common/components/utility_bar/utility_bar_section.tsx @@ -11,10 +11,15 @@ import { BarSection, BarSectionProps } from './styles'; export interface UtilityBarSectionProps extends BarSectionProps { children: React.ReactNode; + dataTestSubj?: string; } -export const UtilityBarSection = React.memo(({ grow, children }) => ( - {children} -)); +export const UtilityBarSection = React.memo( + ({ grow, children, dataTestSubj }) => ( + + {children} + + ) +); UtilityBarSection.displayName = 'UtilityBarSection'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_bulk_actions.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_bulk_actions.tsx index 1c31961690430d..a9da49b6a93163 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_bulk_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_bulk_actions.tsx @@ -88,8 +88,8 @@ export const useBulkActions = ({ ); const { - state: { isAllSelected, rules, loadingRuleIds, selectedRuleIds, isRefreshOn }, - actions: { setLoadingRules, setIsRefreshOn }, + state: { isAllSelected, rules, loadingRuleIds, selectedRuleIds }, + actions: { setLoadingRules, clearRulesSelection }, } = rulesTableContext; return useCallback( @@ -107,7 +107,6 @@ export const useBulkActions = ({ const handleEnableAction = async () => { startTransaction({ name: BULK_RULE_ACTIONS.ENABLE }); - setIsRefreshOn(false); closePopover(); const disabledRules = selectedRules.filter(({ enabled }) => !enabled); @@ -130,12 +129,10 @@ export const useBulkActions = ({ search: isAllSelected ? { query: filterQuery } : { ids: ruleIds }, }); updateRulesCache(res?.attributes?.results?.updated ?? []); - setIsRefreshOn(isRefreshOn); }; const handleDisableActions = async () => { startTransaction({ name: BULK_RULE_ACTIONS.DISABLE }); - setIsRefreshOn(false); closePopover(); const enabledIds = selectedRules.filter(({ enabled }) => enabled).map(({ id }) => id); @@ -148,12 +145,10 @@ export const useBulkActions = ({ search: isAllSelected ? { query: filterQuery } : { ids: enabledIds }, }); updateRulesCache(res?.attributes?.results?.updated ?? []); - setIsRefreshOn(isRefreshOn); }; const handleDuplicateAction = async () => { startTransaction({ name: BULK_RULE_ACTIONS.DUPLICATE }); - setIsRefreshOn(false); closePopover(); await executeRulesBulkAction({ @@ -164,17 +159,15 @@ export const useBulkActions = ({ search: isAllSelected ? { query: filterQuery } : { ids: selectedRuleIds }, }); invalidateRules(); - setIsRefreshOn(isRefreshOn); + clearRulesSelection(); }; const handleDeleteAction = async () => { - setIsRefreshOn(false); closePopover(); if (isAllSelected) { // User has cancelled deletion if ((await confirmDeletion()) === false) { - setIsRefreshOn(isRefreshOn); return; } } @@ -188,11 +181,9 @@ export const useBulkActions = ({ search: isAllSelected ? { query: filterQuery } : { ids: selectedRuleIds }, }); invalidateRules(); - setIsRefreshOn(isRefreshOn); }; const handleExportAction = async () => { - setIsRefreshOn(false); closePopover(); startTransaction({ name: BULK_RULE_ACTIONS.EXPORT }); @@ -203,7 +194,6 @@ export const useBulkActions = ({ toasts, search: isAllSelected ? { query: filterQuery } : { ids: selectedRuleIds }, }); - setIsRefreshOn(isRefreshOn); }; const handleBulkEdit = (bulkEditActionType: BulkActionEditType) => async () => { @@ -211,7 +201,6 @@ export const useBulkActions = ({ let isBulkEditFinished = false; // disabling auto-refresh so user's selected rules won't disappear after table refresh - setIsRefreshOn(false); closePopover(); const customSelectedRuleIds = selectedRules @@ -220,13 +209,11 @@ export const useBulkActions = ({ // User has cancelled edit action or there are no custom rules to proceed if ((await confirmBulkEdit()) === false) { - setIsRefreshOn(isRefreshOn); return; } const editPayload = await completeBulkEditForm(bulkEditActionType); if (editPayload == null) { - setIsRefreshOn(isRefreshOn); return; } @@ -288,7 +275,6 @@ export const useBulkActions = ({ isBulkEditFinished = true; updateRulesCache(res?.attributes?.results?.updated ?? []); - setIsRefreshOn(isRefreshOn); if (getIsMounted()) { await resolveTagsRefetch(bulkEditActionType); } @@ -453,7 +439,6 @@ export const useBulkActions = ({ filterQuery, invalidateRules, confirmDeletion, - setIsRefreshOn, confirmBulkEdit, completeBulkEditForm, queryClient, @@ -461,7 +446,7 @@ export const useBulkActions = ({ getIsMounted, resolveTagsRefetch, updateRulesCache, - isRefreshOn, + clearRulesSelection, ] ); }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table/__mocks__/rules_table_context.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table/__mocks__/rules_table_context.tsx index a6d3aeb35d2a86..86fcdcfe8b3f72 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table/__mocks__/rules_table_context.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table/__mocks__/rules_table_context.tsx @@ -51,6 +51,7 @@ export const useRulesTableContextMock = { setPerPage: jest.fn(), setSelectedRuleIds: jest.fn(), setSortingOptions: jest.fn(), + clearRulesSelection: jest.fn(), }, }), }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table/rules_table_context.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table/rules_table_context.tsx index 9647d2fd528b13..243e70683875f6 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table/rules_table_context.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table/rules_table_context.tsx @@ -5,7 +5,15 @@ * 2.0. */ -import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + useRef, +} from 'react'; import { DEFAULT_RULES_TABLE_REFRESH_SETTING } from '../../../../../../../common/constants'; import { invariant } from '../../../../../../../common/utils/invariant'; import { useKibana, useUiSetting$ } from '../../../../../../common/lib/kibana'; @@ -131,6 +139,10 @@ export interface RulesTableActions { setPerPage: React.Dispatch>; setSelectedRuleIds: React.Dispatch>; setSortingOptions: React.Dispatch>; + /** + * clears rules selection on a page + */ + clearRulesSelection: () => void; } export interface RulesTableContextType { @@ -171,6 +183,7 @@ export const RulesTableContextProvider = ({ const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(DEFAULT_RULES_PER_PAGE); const [selectedRuleIds, setSelectedRuleIds] = useState([]); + const autoRefreshBeforePause = useRef(null); const toggleInMemorySorting = useCallback( (value: boolean) => { @@ -201,6 +214,26 @@ export const RulesTableContextProvider = ({ setIsAllSelected(false); }, []); + const clearRulesSelection = useCallback(() => { + setSelectedRuleIds([]); + setIsAllSelected(false); + }, []); + + useEffect(() => { + // pause table auto refresh when any of rule selected + // store current auto refresh value, to use it later, when all rules selection will be cleared + if (selectedRuleIds.length > 0) { + setIsRefreshOn(false); + if (autoRefreshBeforePause.current == null) { + autoRefreshBeforePause.current = isRefreshOn; + } + } else { + // if no rules selected, update auto refresh value, with previously stored value + setIsRefreshOn(autoRefreshBeforePause.current ?? isRefreshOn); + autoRefreshBeforePause.current = null; + } + }, [selectedRuleIds, isRefreshOn]); + // Fetch rules const { data: { rules, total } = { rules: [], total: 0 }, @@ -265,6 +298,7 @@ export const RulesTableContextProvider = ({ setPerPage, setSelectedRuleIds, setSortingOptions, + clearRulesSelection, }, }), [ @@ -289,7 +323,9 @@ export const RulesTableContextProvider = ({ selectedRuleIds, sortingOptions, toggleInMemorySorting, + setSelectedRuleIds, total, + clearRulesSelection, ] ); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.test.tsx index dbe26402e677a4..91f4a0b06e71d9 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.test.tsx @@ -7,20 +7,15 @@ import React from 'react'; import { mount } from 'enzyme'; -import { ThemeProvider } from 'styled-components'; import { waitFor } from '@testing-library/react'; import { AllRulesUtilityBar } from './utility_bar'; -import { getMockTheme } from '../../../../../common/lib/kibana/kibana_react.mock'; - -const mockTheme = getMockTheme({ - eui: { euiBreakpoints: { l: '1200px' }, euiSizeM: '10px' }, -}); +import { TestProviders } from '../../../../../common/mock'; describe('AllRules', () => { it('renders AllRulesUtilityBar total rules and selected rules', () => { const wrapper = mount( - + { onRefreshSwitch={jest.fn()} hasBulkActions /> - + ); expect(wrapper.find('[data-test-subj="showingRules"]').at(0).text()).toEqual('Showing 4 rules'); @@ -42,7 +37,7 @@ describe('AllRules', () => { it('does not render total selected and bulk actions when "hasBulkActions" is false', () => { const wrapper = mount( - + { onRefreshSwitch={jest.fn()} hasBulkActions={false} /> - + ); expect(wrapper.find('[data-test-subj="showingRules"]').exists()).toBeFalsy(); @@ -65,7 +60,7 @@ describe('AllRules', () => { it('renders utility actions if user has permissions', () => { const wrapper = mount( - + { onRefreshSwitch={jest.fn()} hasBulkActions /> - + ); expect(wrapper.find('[data-test-subj="bulkActions"]').exists()).toBeTruthy(); @@ -84,7 +79,7 @@ describe('AllRules', () => { it('renders no utility actions if user has no permissions', () => { const wrapper = mount( - + { onRefreshSwitch={jest.fn()} hasBulkActions /> - + ); expect(wrapper.find('[data-test-subj="bulkActions"]').exists()).toBeFalsy(); @@ -104,7 +99,7 @@ describe('AllRules', () => { it('invokes refresh on refresh action click', () => { const mockRefresh = jest.fn(); const wrapper = mount( - + { onRefreshSwitch={jest.fn()} hasBulkActions /> - + ); wrapper.find('[data-test-subj="refreshRulesAction"] button').at(0).simulate('click'); @@ -123,21 +118,21 @@ describe('AllRules', () => { expect(mockRefresh).toHaveBeenCalled(); }); - it('invokes onRefreshSwitch when auto refresh switch is clicked', async () => { + it('invokes onRefreshSwitch when auto refresh switch is clicked if there are not selected items', async () => { const mockSwitch = jest.fn(); const wrapper = mount( - + - + ); await waitFor(() => { @@ -146,4 +141,28 @@ describe('AllRules', () => { expect(mockSwitch).toHaveBeenCalledTimes(1); }); }); + + it('does not invokes onRefreshSwitch when auto refresh switch is clicked if there are selected items', async () => { + const mockSwitch = jest.fn(); + const wrapper = mount( + + + + ); + + await waitFor(() => { + wrapper.find('[data-test-subj="refreshSettings"] button').first().simulate('click'); + wrapper.find('[data-test-subj="refreshSettingsSwitch"] button').first().simulate('click'); + expect(mockSwitch).not.toHaveBeenCalled(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx index 5513f70c42297e..4e2f18ea7832d9 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx @@ -11,8 +11,11 @@ import { EuiSwitch, EuiSwitchEvent, EuiContextMenuPanelDescriptor, + EuiTextColor, + EuiSpacer, } from '@elastic/eui'; import React, { useCallback } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; import { UtilityBar, @@ -59,6 +62,7 @@ export const AllRulesUtilityBar = React.memo( }) => { const { timelines } = useKibana().services; const rulesTableContext = useRulesTableContextOptional(); + const isAnyRuleSelected = numberSelectedItems > 0; const handleGetBulkItemsPopoverContent = useCallback( (closePopover: () => void): JSX.Element | null => { @@ -96,12 +100,26 @@ export const AllRulesUtilityBar = React.memo( checked={isAutoRefreshOn ?? false} onChange={handleAutoRefreshSwitch(closePopover)} compressed + disabled={isAnyRuleSelected} data-test-subj="refreshSettingsSwitch" />, + ...(isAnyRuleSelected + ? [ +
+ + + + +
, + ] + : []), ]} /> ), - [isAutoRefreshOn, handleAutoRefreshSwitch] + [isAutoRefreshOn, handleAutoRefreshSwitch, isAnyRuleSelected] ); return ( @@ -186,7 +204,7 @@ export const AllRulesUtilityBar = React.memo( )} {rulesTableContext && ( - + {timelines.getLastUpdated({ showUpdating: rulesTableContext.state.isFetching, updatedAt: rulesTableContext.state.lastUpdated, From fc2c3ec10f549c3fcd96250429b2da934f909f46 Mon Sep 17 00:00:00 2001 From: Yaroslav Kuznietsov Date: Fri, 8 Jul 2022 18:26:55 +0300 Subject: [PATCH 16/47] [Data] Fixed Date histogram bounds calculation doesn't update "now" (#135899) * Fixed the problem with date histogram bounds calculation. * Update src/plugins/data/server/search/aggs/aggs_service.ts Co-authored-by: Anton Dosov Co-authored-by: Anton Dosov --- src/plugins/data/common/index.ts | 1 + .../data/common/search/aggs/aggs_service.ts | 9 ++++----- src/plugins/data/public/plugin.ts | 1 - .../public/search/aggs/aggs_service.test.ts | 2 +- .../data/public/search/aggs/aggs_service.ts | 20 +++++++++++++------ .../data/public/search/search_service.ts | 6 +++--- .../data/server/search/aggs/aggs_service.ts | 15 ++++++++++++-- 7 files changed, 36 insertions(+), 18 deletions(-) diff --git a/src/plugins/data/common/index.ts b/src/plugins/data/common/index.ts index 478d6d23d7b969..1a929df039ec06 100644 --- a/src/plugins/data/common/index.ts +++ b/src/plugins/data/common/index.ts @@ -28,6 +28,7 @@ export * from './search'; export type { RefreshInterval, TimeRangeBounds, + TimeRange, GetConfigFn, SavedQuery, SavedQueryAttributes, diff --git a/src/plugins/data/common/search/aggs/aggs_service.ts b/src/plugins/data/common/search/aggs/aggs_service.ts index f88138d04f31a3..a367baf5a53721 100644 --- a/src/plugins/data/common/search/aggs/aggs_service.ts +++ b/src/plugins/data/common/search/aggs/aggs_service.ts @@ -9,8 +9,7 @@ import { ExpressionsServiceSetup } from '@kbn/expressions-plugin/common'; import type { DataView } from '@kbn/data-views-plugin/common'; import type { FieldFormatsStartCommon } from '@kbn/field-formats-plugin/common'; -import { CalculateBoundsOptions } from '../../query'; -import { UI_SETTINGS, AggTypesDependencies, calculateBounds } from '../..'; +import { UI_SETTINGS, AggTypesDependencies } from '../..'; import { GetConfigFn } from '../../types'; import { AggConfigs, @@ -42,7 +41,7 @@ export interface AggsCommonStartDependencies { getIndexPattern(id: string): Promise; getConfig: GetConfigFn; fieldFormats: FieldFormatsStartCommon; - calculateBoundsOptions: CalculateBoundsOptions; + calculateBounds: AggTypesDependencies['calculateBounds']; } /** @@ -75,13 +74,13 @@ export class AggsCommonService { public start({ getConfig, fieldFormats, - calculateBoundsOptions, + calculateBounds, }: AggsCommonStartDependencies): AggsCommonStart { const aggTypesStart = this.aggTypesRegistry.start({ getConfig, getFieldFormatsStart: () => fieldFormats, aggExecutionContext: this.aggExecutionContext, - calculateBounds: (timeRange) => calculateBounds(timeRange, calculateBoundsOptions), + calculateBounds, }); return { diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index d0a603b52b9212..22ebf1c9d80364 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -137,7 +137,6 @@ export class DataPublicPlugin fieldFormats, indexPatterns: dataViews, screenshotMode, - nowProvider: this.nowProvider, }); setSearchService(search); diff --git a/src/plugins/data/public/search/aggs/aggs_service.test.ts b/src/plugins/data/public/search/aggs/aggs_service.test.ts index c391c023b2d27d..40cc3590a32e24 100644 --- a/src/plugins/data/public/search/aggs/aggs_service.test.ts +++ b/src/plugins/data/public/search/aggs/aggs_service.test.ts @@ -34,11 +34,11 @@ describe('AggsService - public', () => { setupDeps = { registerFunction: expressionsPluginMock.createSetupContract().registerFunction, uiSettings, + nowProvider: createNowProviderMock(), }; startDeps = { fieldFormats: fieldFormatsServiceMock.createStartContract(), indexPatterns: dataPluginMock.createStartContract().indexPatterns, - nowProvider: createNowProviderMock(), }; }); diff --git a/src/plugins/data/public/search/aggs/aggs_service.ts b/src/plugins/data/public/search/aggs/aggs_service.ts index 640bb954561a1a..5a830b6cc154c2 100644 --- a/src/plugins/data/public/search/aggs/aggs_service.ts +++ b/src/plugins/data/public/search/aggs/aggs_service.ts @@ -17,6 +17,7 @@ import { AggsCommonStartDependencies, AggsCommonService, } from '../../../common/search/aggs'; +import { calculateBounds, TimeRange } from '../../../common'; import type { AggsSetup, AggsStart } from './types'; import type { NowProviderInternalContract } from '../../now_provider'; @@ -49,13 +50,13 @@ export function createGetConfig( export interface AggsSetupDependencies { uiSettings: IUiSettingsClient; registerFunction: ExpressionsServiceSetup['registerFunction']; + nowProvider: NowProviderInternalContract; } /** @internal */ export interface AggsStartDependencies { fieldFormats: FieldFormatsStart; indexPatterns: DataViewsContract; - nowProvider: NowProviderInternalContract; } /** @@ -69,8 +70,17 @@ export class AggsService { }); private getConfig?: AggsCommonStartDependencies['getConfig']; private subscriptions: Subscription[] = []; + private nowProvider!: NowProviderInternalContract; + + /** + * NowGetter uses window.location, so we must have a separate implementation + * of calculateBounds on the client and the server. + */ + private calculateBounds = (timeRange: TimeRange) => + calculateBounds(timeRange, { forceNow: this.nowProvider.get() }); - public setup({ registerFunction, uiSettings }: AggsSetupDependencies): AggsSetup { + public setup({ registerFunction, uiSettings, nowProvider }: AggsSetupDependencies): AggsSetup { + this.nowProvider = nowProvider; this.getConfig = createGetConfig(uiSettings, aggsRequiredUiSettings, this.subscriptions); return this.aggsCommonService.setup({ @@ -78,14 +88,12 @@ export class AggsService { }); } - public start({ indexPatterns, fieldFormats, nowProvider }: AggsStartDependencies): AggsStart { + public start({ indexPatterns, fieldFormats }: AggsStartDependencies): AggsStart { const { calculateAutoTimeExpression, types, createAggConfigs } = this.aggsCommonService.start({ getConfig: this.getConfig!, getIndexPattern: indexPatterns.get, + calculateBounds: this.calculateBounds, fieldFormats, - calculateBoundsOptions: { - forceNow: nowProvider.get(), - }, }); return { diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts index a945e3d8a5479b..eabc101d7a237c 100644 --- a/src/plugins/data/public/search/search_service.ts +++ b/src/plugins/data/public/search/search_service.ts @@ -89,7 +89,6 @@ export interface SearchServiceStartDependencies { fieldFormats: FieldFormatsStart; indexPatterns: DataViewsContract; screenshotMode: ScreenshotModePluginStart; - nowProvider: NowProviderInternalContract; } export class SearchService implements Plugin { @@ -191,6 +190,7 @@ export class SearchService implements Plugin { const aggs = this.aggsService.setup({ uiSettings, registerFunction: expressions.registerFunction, + nowProvider, }); if (this.initializerContext.config.get().search.aggs.shardDelay.enabled) { @@ -223,7 +223,7 @@ export class SearchService implements Plugin { public start( { http, theme, uiSettings, chrome, application }: CoreStart, - { fieldFormats, indexPatterns, screenshotMode, nowProvider }: SearchServiceStartDependencies + { fieldFormats, indexPatterns, screenshotMode }: SearchServiceStartDependencies ): ISearchStart { const search = ((request, options = {}) => { return this.searchInterceptor.search(request, options); @@ -232,7 +232,7 @@ export class SearchService implements Plugin { const loadingCount$ = new BehaviorSubject(0); http.addLoadingCountSource(loadingCount$); - const aggs = this.aggsService.start({ fieldFormats, indexPatterns, nowProvider }); + const aggs = this.aggsService.start({ fieldFormats, indexPatterns }); const searchSourceDependencies: SearchSourceDependencies = { aggs, diff --git a/src/plugins/data/server/search/aggs/aggs_service.ts b/src/plugins/data/server/search/aggs/aggs_service.ts index 57f9ccebee23c5..ab4fdbb57aa4db 100644 --- a/src/plugins/data/server/search/aggs/aggs_service.ts +++ b/src/plugins/data/server/search/aggs/aggs_service.ts @@ -17,7 +17,12 @@ import type { import { ExpressionsServiceSetup } from '@kbn/expressions-plugin/common'; import { FieldFormatsStart } from '@kbn/field-formats-plugin/server'; import { DataViewsServerPluginStart } from '@kbn/data-views-plugin/server'; -import { AggsCommonService, aggsRequiredUiSettings } from '../../../common'; +import { + calculateBounds, + AggsCommonService, + aggsRequiredUiSettings, + TimeRange, +} from '../../../common'; import { AggsSetup, AggsStart } from './types'; /** @internal */ @@ -48,6 +53,12 @@ async function getConfigFn(uiSettingsClient: IUiSettingsClient) { export class AggsService { private readonly aggsCommonService = new AggsCommonService({ shouldDetectTimeZone: false }); + /** + * getForceNow uses window.location on the client, so we must have a + * separate implementation of calculateBounds on the server. + */ + private calculateBounds = (timeRange: TimeRange) => calculateBounds(timeRange); + public setup({ registerFunction }: AggsSetupDependencies): AggsSetup { return this.aggsCommonService.setup({ registerFunction, @@ -65,10 +76,10 @@ export class AggsService { this.aggsCommonService.start({ getConfig: await getConfigFn(uiSettingsClient), fieldFormats: await fieldFormats.fieldFormatServiceFactory(uiSettingsClient), - calculateBoundsOptions: {}, getIndexPattern: ( await indexPatterns.dataViewsServiceFactory(savedObjectsClient, elasticsearchClient) ).get, + calculateBounds: this.calculateBounds, }); return { From eb6e6477b2a6d99048d92917136a1918eae87ddf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yulia=20=C4=8Cech?= <6585477+yuliacech@users.noreply.github.com> Date: Fri, 8 Jul 2022 17:43:10 +0200 Subject: [PATCH 17/47] Added a gif module to be able to import gif files (#136015) --- packages/kbn-ambient-ui-types/index.d.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/kbn-ambient-ui-types/index.d.ts b/packages/kbn-ambient-ui-types/index.d.ts index 3d5cb057788293..7f2f3cf1d0089e 100644 --- a/packages/kbn-ambient-ui-types/index.d.ts +++ b/packages/kbn-ambient-ui-types/index.d.ts @@ -24,6 +24,12 @@ declare module '*.svg' { export default content; } +declare module '*.gif' { + const content: string; + // eslint-disable-next-line import/no-default-export + export default content; +} + declare module '*.mdx' { let MDXComponent: (props: any) => JSX.Element; // eslint-disable-next-line import/no-default-export From 9dbf147d6673ecce8cbbe591f7216db0c4378aee Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Fri, 8 Jul 2022 09:01:49 -0700 Subject: [PATCH 18/47] Add openAPI specifications for get comments API (#135921) --- .../api/cases/cases-api-get-comments.asciidoc | 32 +- .../plugins/cases/docs/openapi/bundled.json | 285 ++++++++++++++++++ .../plugins/cases/docs/openapi/bundled.yaml | 180 +++++++++++ .../examples/get_comment_response.yaml | 19 ++ .../components/parameters/comment_id.yaml | 6 +- .../cases/docs/openapi/entrypoint.yaml | 8 +- .../paths/api@cases@{caseid}@comments.yaml | 27 ++ ...i@cases@{caseid}@comments@{commentid}.yaml | 27 ++ ...{spaceid}@api@cases@{caseid}@comments.yaml | 28 ++ ...i@cases@{caseid}@comments@{commentid}.yaml | 28 ++ 10 files changed, 618 insertions(+), 22 deletions(-) create mode 100644 x-pack/plugins/cases/docs/openapi/components/examples/get_comment_response.yaml diff --git a/docs/api/cases/cases-api-get-comments.asciidoc b/docs/api/cases/cases-api-get-comments.asciidoc index 103731cd04dd77..58c4c32acfa15c 100644 --- a/docs/api/cases/cases-api-get-comments.asciidoc +++ b/docs/api/cases/cases-api-get-comments.asciidoc @@ -47,12 +47,12 @@ default space is used. === {api-examples-title} -Retrieves comment ID `71ec1870-725b-11ea-a0b2-c51ea50a58e2` from case ID -`a18b38a0-71b0-11ea-a0b2-c51ea50a58e2`: +Retrieves comment ID `8048b460-fe2b-11ec-b15d-779a7c8bbcc3` from case ID +`ecbf8a20-fe2a-11ec-b15d-779a7c8bbcc3`: [source,sh] -------------------------------------------------- -GET api/cases/a18b38a0-71b0-11ea-a0b2-c51ea50a58e2/comments/71ec1870-725b-11ea-a0b2-c51ea50a58e2 +GET api/cases/ecbf8a20-fe2a-11ec-b15d-779a7c8bbcc3/comments/8048b460-fe2b-11ec-b15d-779a7c8bbcc3 -------------------------------------------------- // KIBANA @@ -61,20 +61,20 @@ The API returns the requested comment JSON object. For example: [source,json] -------------------------------------------------- { - "id":"8acb3a80-ab0a-11ec-985f-97e55adae8b9", - "version":"Wzc5NzYsM10=", - "comment":"Start operation bubblegum immediately! And chew fast!", + "id":"8048b460-fe2b-11ec-b15d-779a7c8bbcc3", + "version":"WzIzLDFd", "type":"user", "owner":"cases", - "created_at":"2022-03-24T00:37:10.832Z", - "created_by": { - "email": "classified@hms.oo.gov.uk", - "full_name": "Classified", - "username": "M" - }, - "pushed_at": null, - "pushed_by": null, - "updated_at": null, - "updated_by": null + "comment":"A new comment", + "created_at":"2022-07-07T19:32:13.104Z", + "created_by":{ + "email":null, + "full_name":null, + "username":"elastic" + }, + "pushed_at":null, + "pushed_by":null, + "updated_at":null, + "updated_by":null } -------------------------------------------------- \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/bundled.json b/x-pack/plugins/cases/docs/openapi/bundled.json index 24cdc4a35303b0..a5a62a2d5615d3 100644 --- a/x-pack/plugins/cases/docs/openapi/bundled.json +++ b/x-pack/plugins/cases/docs/openapi/bundled.json @@ -3313,6 +3313,129 @@ } ] }, + "get": { + "summary": "Retrieves all the comments from a case.", + "description": "You must have `read` privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the cases with the comments you're seeking.\n", + "tags": [ + "cases", + "kibana" + ], + "deprecated": true, + "parameters": [ + { + "$ref": "#/components/parameters/case_id" + } + ], + "responses": { + "200": { + "description": "Indicates a successful call.", + "content": { + "application/json; charset=utf-8": { + "schema": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/alert_comment_response_properties" + }, + { + "$ref": "#/components/schemas/user_comment_response_properties" + } + ] + } + } + }, + "examples": {} + } + } + }, + "servers": [ + { + "url": "https://localhost:5601" + } + ] + }, + "servers": [ + { + "url": "https://localhost:5601" + } + ] + }, + "/api/cases/{caseId}/comments/{commentId}": { + "delete": { + "summary": "Deletes a comment or alert from a case.", + "description": "You must have `all` privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the cases you're deleting.\n", + "tags": [ + "cases", + "kibana" + ], + "parameters": [ + { + "$ref": "#/components/parameters/kbn_xsrf" + }, + { + "$ref": "#/components/parameters/case_id" + }, + { + "$ref": "#/components/parameters/comment_id" + } + ], + "responses": { + "204": { + "description": "Indicates a successful call." + } + }, + "servers": [ + { + "url": "https://localhost:5601" + } + ] + }, + "get": { + "summary": "Retrieves a comment from a case.", + "description": "You must have `read` privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the cases with the comments you're seeking.\n", + "tags": [ + "cases", + "kibana" + ], + "parameters": [ + { + "$ref": "#/components/parameters/case_id" + }, + { + "$ref": "#/components/parameters/comment_id" + } + ], + "responses": { + "200": { + "description": "Indicates a successful call.", + "content": { + "application/json; charset=utf-8": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/alert_comment_response_properties" + }, + { + "$ref": "#/components/schemas/user_comment_response_properties" + } + ] + }, + "examples": { + "getCaseCommentResponse": { + "$ref": "#/components/examples/get_comment_response" + } + } + } + } + } + }, + "servers": [ + { + "url": "https://localhost:5601" + } + ] + }, "servers": [ { "url": "https://localhost:5601" @@ -6645,6 +6768,138 @@ } ] }, + "get": { + "summary": "Retrieves all the comments from a case.", + "description": "You must have `read` privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the cases with the comments you're seeking.\n", + "deprecated": true, + "tags": [ + "cases", + "kibana" + ], + "parameters": [ + { + "$ref": "#/components/parameters/case_id" + }, + { + "$ref": "#/components/parameters/space_id" + } + ], + "responses": { + "200": { + "description": "Indicates a successful call.", + "content": { + "application/json; charset=utf-8": { + "schema": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/alert_comment_response_properties" + }, + { + "$ref": "#/components/schemas/user_comment_response_properties" + } + ] + } + } + }, + "examples": {} + } + } + }, + "servers": [ + { + "url": "https://localhost:5601" + } + ] + }, + "servers": [ + { + "url": "https://localhost:5601" + } + ] + }, + "/s/{spaceId}/api/cases/{caseId}/comments/{commentId}": { + "delete": { + "summary": "Deletes a comment or alert from a case.", + "description": "You must have `all` privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the cases you're deleting.\n", + "tags": [ + "cases", + "kibana" + ], + "parameters": [ + { + "$ref": "#/components/parameters/kbn_xsrf" + }, + { + "$ref": "#/components/parameters/case_id" + }, + { + "$ref": "#/components/parameters/comment_id" + }, + { + "$ref": "#/components/parameters/space_id" + } + ], + "responses": { + "204": { + "description": "Indicates a successful call." + } + }, + "servers": [ + { + "url": "https://localhost:5601" + } + ] + }, + "get": { + "summary": "Retrieves a comment from a case.", + "description": "You must have `read` privileges for the **Cases** feature in the **Management**, **Observability**, or **Security*** section of the Kibana feature privileges, depending on the owner of the cases with the comments you're seeking.\n", + "tags": [ + "cases", + "kibana" + ], + "parameters": [ + { + "$ref": "#/components/parameters/case_id" + }, + { + "$ref": "#/components/parameters/comment_id" + }, + { + "$ref": "#/components/parameters/space_id" + } + ], + "responses": { + "200": { + "description": "Indicates a successful call.", + "content": { + "application/json; charset=utf-8": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/alert_comment_response_properties" + }, + { + "$ref": "#/components/schemas/user_comment_response_properties" + } + ] + }, + "examples": { + "getCaseCommentResponse": { + "$ref": "#/components/examples/get_comment_response" + } + } + } + } + } + }, + "servers": [ + { + "url": "https://localhost:5601" + } + ] + }, "servers": [ { "url": "https://localhost:5601" @@ -6736,6 +6991,16 @@ "example": "9c235210-6834-11ea-a78c-6ffb38a34414" } }, + "comment_id": { + "in": "path", + "name": "commentId", + "description": "The identifier for the comment. To retrieve comment IDs, use the get case or find cases APIs.\n", + "required": true, + "schema": { + "type": "string", + "example": "71ec1870-725b-11ea-a0b2-c51ea50a58e2" + } + }, "space_id": { "in": "path", "name": "spaceId", @@ -7581,6 +7846,26 @@ }, "external_service": null } + }, + "get_comment_response": { + "summary": "A single user comment retrieved from a case", + "value": { + "id": "8048b460-fe2b-11ec-b15d-779a7c8bbcc3", + "version": "WzIzLDFd", + "type": "user", + "owner": "cases", + "comment": "A new comment", + "created_at": "2022-07-07T19:32:13.104Z", + "created_by": { + "email": null, + "full_name": null, + "username": "elastic" + }, + "pushed_at": null, + "pushed_by": null, + "updated_at": null, + "updated_by": null + } } } }, diff --git a/x-pack/plugins/cases/docs/openapi/bundled.yaml b/x-pack/plugins/cases/docs/openapi/bundled.yaml index bf9f7823d09501..07831d6e70a509 100644 --- a/x-pack/plugins/cases/docs/openapi/bundled.yaml +++ b/x-pack/plugins/cases/docs/openapi/bundled.yaml @@ -2751,6 +2751,81 @@ paths: $ref: '#/components/examples/update_comment_response' servers: - url: https://localhost:5601 + get: + summary: Retrieves all the comments from a case. + description: > + You must have `read` privileges for the **Cases** feature in the + **Management**, **Observability**, or **Security** section of the Kibana + feature privileges, depending on the owner of the cases with the + comments you're seeking. + tags: + - cases + - kibana + deprecated: true + parameters: + - $ref: '#/components/parameters/case_id' + responses: + '200': + description: Indicates a successful call. + content: + application/json; charset=utf-8: + schema: + type: array + items: + anyOf: + - $ref: '#/components/schemas/alert_comment_response_properties' + - $ref: '#/components/schemas/user_comment_response_properties' + examples: {} + servers: + - url: https://localhost:5601 + servers: + - url: https://localhost:5601 + /api/cases/{caseId}/comments/{commentId}: + delete: + summary: Deletes a comment or alert from a case. + description: > + You must have `all` privileges for the **Cases** feature in the + **Management**, **Observability**, or **Security** section of the Kibana + feature privileges, depending on the owner of the cases you're deleting. + tags: + - cases + - kibana + parameters: + - $ref: '#/components/parameters/kbn_xsrf' + - $ref: '#/components/parameters/case_id' + - $ref: '#/components/parameters/comment_id' + responses: + '204': + description: Indicates a successful call. + servers: + - url: https://localhost:5601 + get: + summary: Retrieves a comment from a case. + description: > + You must have `read` privileges for the **Cases** feature in the + **Management**, **Observability**, or **Security** section of the Kibana + feature privileges, depending on the owner of the cases with the + comments you're seeking. + tags: + - cases + - kibana + parameters: + - $ref: '#/components/parameters/case_id' + - $ref: '#/components/parameters/comment_id' + responses: + '200': + description: Indicates a successful call. + content: + application/json; charset=utf-8: + schema: + oneOf: + - $ref: '#/components/schemas/alert_comment_response_properties' + - $ref: '#/components/schemas/user_comment_response_properties' + examples: + getCaseCommentResponse: + $ref: '#/components/examples/get_comment_response' + servers: + - url: https://localhost:5601 servers: - url: https://localhost:5601 /s/{spaceId}/api/cases: @@ -5507,6 +5582,84 @@ paths: $ref: '#/components/examples/update_comment_response' servers: - url: https://localhost:5601 + get: + summary: Retrieves all the comments from a case. + description: > + You must have `read` privileges for the **Cases** feature in the + **Management**, **Observability**, or **Security** section of the Kibana + feature privileges, depending on the owner of the cases with the + comments you're seeking. + deprecated: true + tags: + - cases + - kibana + parameters: + - $ref: '#/components/parameters/case_id' + - $ref: '#/components/parameters/space_id' + responses: + '200': + description: Indicates a successful call. + content: + application/json; charset=utf-8: + schema: + type: array + items: + anyOf: + - $ref: '#/components/schemas/alert_comment_response_properties' + - $ref: '#/components/schemas/user_comment_response_properties' + examples: {} + servers: + - url: https://localhost:5601 + servers: + - url: https://localhost:5601 + /s/{spaceId}/api/cases/{caseId}/comments/{commentId}: + delete: + summary: Deletes a comment or alert from a case. + description: > + You must have `all` privileges for the **Cases** feature in the + **Management**, **Observability**, or **Security** section of the Kibana + feature privileges, depending on the owner of the cases you're deleting. + tags: + - cases + - kibana + parameters: + - $ref: '#/components/parameters/kbn_xsrf' + - $ref: '#/components/parameters/case_id' + - $ref: '#/components/parameters/comment_id' + - $ref: '#/components/parameters/space_id' + responses: + '204': + description: Indicates a successful call. + servers: + - url: https://localhost:5601 + get: + summary: Retrieves a comment from a case. + description: > + You must have `read` privileges for the **Cases** feature in the + **Management**, **Observability**, or **Security*** section of the + Kibana feature privileges, depending on the owner of the cases with the + comments you're seeking. + tags: + - cases + - kibana + parameters: + - $ref: '#/components/parameters/case_id' + - $ref: '#/components/parameters/comment_id' + - $ref: '#/components/parameters/space_id' + responses: + '200': + description: Indicates a successful call. + content: + application/json; charset=utf-8: + schema: + oneOf: + - $ref: '#/components/schemas/alert_comment_response_properties' + - $ref: '#/components/schemas/user_comment_response_properties' + examples: + getCaseCommentResponse: + $ref: '#/components/examples/get_comment_response' + servers: + - url: https://localhost:5601 servers: - url: https://localhost:5601 components: @@ -5576,6 +5729,16 @@ components: schema: type: string example: 9c235210-6834-11ea-a78c-6ffb38a34414 + comment_id: + in: path + name: commentId + description: > + The identifier for the comment. To retrieve comment IDs, use the get + case or find cases APIs. + required: true + schema: + type: string + example: 71ec1870-725b-11ea-a0b2-c51ea50a58e2 space_id: in: path name: spaceId @@ -6278,6 +6441,23 @@ components: type: .none fields: null external_service: null + get_comment_response: + summary: A single user comment retrieved from a case + value: + id: 8048b460-fe2b-11ec-b15d-779a7c8bbcc3 + version: WzIzLDFd + type: user + owner: cases + comment: A new comment + created_at: '2022-07-07T19:32:13.104Z' + created_by: + email: null + full_name: null + username: elastic + pushed_at: null + pushed_by: null + updated_at: null + updated_by: null security: - basicAuth: [] - apiKeyAuth: [] diff --git a/x-pack/plugins/cases/docs/openapi/components/examples/get_comment_response.yaml b/x-pack/plugins/cases/docs/openapi/components/examples/get_comment_response.yaml new file mode 100644 index 00000000000000..dd2baedd8eda35 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/examples/get_comment_response.yaml @@ -0,0 +1,19 @@ +summary: A single user comment retrieved from a case +value: + { + "id":"8048b460-fe2b-11ec-b15d-779a7c8bbcc3", + "version":"WzIzLDFd", + "type":"user", + "owner":"cases", + "comment":"A new comment", + "created_at":"2022-07-07T19:32:13.104Z", + "created_by":{ + "email":null, + "full_name":null, + "username":"elastic" + }, + "pushed_at":null, + "pushed_by":null, + "updated_at":null, + "updated_by":null + } \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/components/parameters/comment_id.yaml b/x-pack/plugins/cases/docs/openapi/components/parameters/comment_id.yaml index 41c25d8a03dc59..a46f47569e8d2d 100644 --- a/x-pack/plugins/cases/docs/openapi/components/parameters/comment_id.yaml +++ b/x-pack/plugins/cases/docs/openapi/components/parameters/comment_id.yaml @@ -1,7 +1,9 @@ in: path name: commentId -description: The identifier for the comment. To retrieve comment IDs, use the get case or find cases APIs. If it is not specified, all comments are deleted. -required: false +description: > + The identifier for the comment. To retrieve comment IDs, use the get case or + find cases APIs. +required: true schema: type: string example: '71ec1870-725b-11ea-a0b2-c51ea50a58e2' \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/entrypoint.yaml b/x-pack/plugins/cases/docs/openapi/entrypoint.yaml index c476e67c7ad6d6..18332efda8be44 100644 --- a/x-pack/plugins/cases/docs/openapi/entrypoint.yaml +++ b/x-pack/plugins/cases/docs/openapi/entrypoint.yaml @@ -41,8 +41,8 @@ paths: # $ref: 'paths/api@cases@{caseid}@alerts.yaml' '/api/cases/{caseId}/comments': $ref: 'paths/api@cases@{caseid}@comments.yaml' -# '/api/cases/{caseId}/comments/{commentId}': -# $ref: 'paths/api@cases@{caseid}@comments@{commentid}.yaml' + '/api/cases/{caseId}/comments/{commentId}': + $ref: 'paths/api@cases@{caseid}@comments@{commentid}.yaml' # '/api/cases/{caseId}/connector/{connectorId}/_push': # $ref: 'paths/api@cases@{caseid}@connector@{connectorid}@_push.yaml' # '/api/cases/{caseId}/user_actions': @@ -72,8 +72,8 @@ paths: # $ref: 'paths/s@{spaceid}@api@cases@{caseid}@alerts.yaml' '/s/{spaceId}/api/cases/{caseId}/comments': $ref: 'paths/s@{spaceid}@api@cases@{caseid}@comments.yaml' -# '/s/{spaceId}/api/cases/{caseId}/comments/{commentId}': -# $ref: 'paths/s@{spaceid}@api@cases@{caseid}@comments@{commentid}.yaml' + '/s/{spaceId}/api/cases/{caseId}/comments/{commentId}': + $ref: 'paths/s@{spaceid}@api@cases@{caseid}@comments@{commentid}.yaml' # '/s/{spaceId}/api/cases/{caseId}/connector/{connectorId}/_push': # $ref: 'paths/s@{spaceid}@api@cases@{caseid}@connector@{connectorid}@_push.yaml' # '/s/{spaceId}/api/cases/{caseId}/user_actions': diff --git a/x-pack/plugins/cases/docs/openapi/paths/api@cases@{caseid}@comments.yaml b/x-pack/plugins/cases/docs/openapi/paths/api@cases@{caseid}@comments.yaml index 8e719ad40f669b..15fa137fa64b47 100644 --- a/x-pack/plugins/cases/docs/openapi/paths/api@cases@{caseid}@comments.yaml +++ b/x-pack/plugins/cases/docs/openapi/paths/api@cases@{caseid}@comments.yaml @@ -91,5 +91,32 @@ patch: servers: - url: https://localhost:5601 +get: + summary: Retrieves all the comments from a case. + description: > + You must have `read` privileges for the **Cases** feature in the **Management**, + **Observability**, or **Security** section of the Kibana feature privileges, + depending on the owner of the cases with the comments you're seeking. + tags: + - cases + - kibana + deprecated: true + parameters: + - $ref: ../components/parameters/case_id.yaml + responses: + '200': + description: Indicates a successful call. + content: + application/json; charset=utf-8: + schema: + type: array + items: + anyOf: + - $ref: '../components/schemas/alert_comment_response_properties.yaml' + - $ref: '../components/schemas/user_comment_response_properties.yaml' + examples: {} + servers: + - url: https://localhost:5601 + servers: - url: https://localhost:5601 \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/paths/api@cases@{caseid}@comments@{commentid}.yaml b/x-pack/plugins/cases/docs/openapi/paths/api@cases@{caseid}@comments@{commentid}.yaml index a89edd52474724..0b167d3e8d25c5 100644 --- a/x-pack/plugins/cases/docs/openapi/paths/api@cases@{caseid}@comments@{commentid}.yaml +++ b/x-pack/plugins/cases/docs/openapi/paths/api@cases@{caseid}@comments@{commentid}.yaml @@ -17,5 +17,32 @@ delete: servers: - url: https://localhost:5601 +get: + summary: Retrieves a comment from a case. + description: > + You must have `read` privileges for the **Cases** feature in the **Management**, + **Observability**, or **Security** section of the Kibana feature privileges, + depending on the owner of the cases with the comments you're seeking. + tags: + - cases + - kibana + parameters: + - $ref: '../components/parameters/case_id.yaml' + - $ref: '../components/parameters/comment_id.yaml' + responses: + '200': + description: Indicates a successful call. + content: + application/json; charset=utf-8: + schema: + oneOf: + - $ref: '../components/schemas/alert_comment_response_properties.yaml' + - $ref: '../components/schemas/user_comment_response_properties.yaml' + examples: + getCaseCommentResponse: + $ref: '../components/examples/get_comment_response.yaml' + servers: + - url: https://localhost:5601 + servers: - url: https://localhost:5601 \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@{caseid}@comments.yaml b/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@{caseid}@comments.yaml index 0e1960bdce5133..dc07c62c38c505 100644 --- a/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@{caseid}@comments.yaml +++ b/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@{caseid}@comments.yaml @@ -94,5 +94,33 @@ patch: servers: - url: https://localhost:5601 +get: + summary: Retrieves all the comments from a case. + description: > + You must have `read` privileges for the **Cases** feature in the **Management**, + **Observability**, or **Security** section of the Kibana feature privileges, + depending on the owner of the cases with the comments you're seeking. + deprecated: true + tags: + - cases + - kibana + parameters: + - $ref: '../components/parameters/case_id.yaml' + - $ref: '../components/parameters/space_id.yaml' + responses: + '200': + description: Indicates a successful call. + content: + application/json; charset=utf-8: + schema: + type: array + items: + anyOf: + - $ref: '../components/schemas/alert_comment_response_properties.yaml' + - $ref: '../components/schemas/user_comment_response_properties.yaml' + examples: {} + servers: + - url: https://localhost:5601 + servers: - url: https://localhost:5601 \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@{caseid}@comments@{commentid}.yaml b/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@{caseid}@comments@{commentid}.yaml index 4970fa9ec7be23..c9ad642bdd5592 100644 --- a/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@{caseid}@comments@{commentid}.yaml +++ b/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@{caseid}@comments@{commentid}.yaml @@ -18,5 +18,33 @@ delete: servers: - url: https://localhost:5601 +get: + summary: Retrieves a comment from a case. + description: > + You must have `read` privileges for the **Cases** feature in the **Management**, + **Observability**, or **Security*** section of the Kibana feature privileges, + depending on the owner of the cases with the comments you're seeking. + tags: + - cases + - kibana + parameters: + - $ref: '../components/parameters/case_id.yaml' + - $ref: '../components/parameters/comment_id.yaml' + - $ref: '../components/parameters/space_id.yaml' + responses: + '200': + description: Indicates a successful call. + content: + application/json; charset=utf-8: + schema: + oneOf: + - $ref: '../components/schemas/alert_comment_response_properties.yaml' + - $ref: '../components/schemas/user_comment_response_properties.yaml' + examples: + getCaseCommentResponse: + $ref: '../components/examples/get_comment_response.yaml' + servers: + - url: https://localhost:5601 + servers: - url: https://localhost:5601 \ No newline at end of file From f5e87246475171083e99e96ff3052c19151a2d0c Mon Sep 17 00:00:00 2001 From: Mark Hopkin Date: Fri, 8 Jul 2022 17:34:38 +0100 Subject: [PATCH 19/47] [Fleet] Display package verification status (#135928) * add attributes.type to fleet error * return verification key ID as part of status response * show verification status of installed integrations * badge on installed integrations tab * show unverified status on overview page * Do now show labels on available packages list * show release label and verificatiomn status * update stories * self review * fix badge label * update openapi * add unit tests for verification function * review comments --- .../plugins/fleet/common/openapi/bundled.json | 3 + .../plugins/fleet/common/openapi/bundled.yaml | 2 + .../schemas/fleet_status_response.yaml | 2 + .../plugins/fleet/common/types/models/epm.ts | 2 + .../fleet/common/types/rest_spec/error.ts | 16 +++ .../common/types/rest_spec/fleet_setup.ts | 1 + .../fleet/common/types/rest_spec/index.ts | 15 +- .../integrations/layouts/default.tsx | 73 ++++++---- .../epm/components/package_card.stories.tsx | 60 +++----- .../sections/epm/components/package_card.tsx | 29 +++- .../epm/components/package_list_grid.tsx | 12 +- .../epm/screens/detail/overview/overview.tsx | 31 +++- .../epm/screens/home/available_packages.tsx | 14 +- .../sections/epm/screens/home/index.tsx | 59 ++++++-- .../epm/screens/home/installed_packages.tsx | 70 ++++++--- .../fleet/public/hooks/use_fleet_status.tsx | 2 + x-pack/plugins/fleet/public/services/index.ts | 2 +- .../services/package_verification.test.ts | 135 ++++++++++++++++++ .../public/services/package_verification.ts | 26 ++++ .../plugins/fleet/server/errors/handlers.ts | 2 +- x-pack/plugins/fleet/server/errors/index.ts | 7 +- .../fleet/server/routes/setup/handlers.ts | 7 + .../epm/packages/package_verification.ts | 8 ++ 23 files changed, 453 insertions(+), 125 deletions(-) create mode 100644 x-pack/plugins/fleet/common/types/rest_spec/error.ts create mode 100644 x-pack/plugins/fleet/public/services/package_verification.test.ts create mode 100644 x-pack/plugins/fleet/public/services/package_verification.ts diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json index 0b32651376f95f..53e1b9f46568d5 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.json +++ b/x-pack/plugins/fleet/common/openapi/bundled.json @@ -4082,6 +4082,9 @@ "encrypted_saved_object_encryption_key_required" ] } + }, + "package_verification_key_id": { + "type": "string" } }, "required": [ diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml index d2044f9e631ce1..20fe96ac4cd2d2 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.yaml +++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml @@ -2557,6 +2557,8 @@ components: type: string enum: - encrypted_saved_object_encryption_key_required + package_verification_key_id: + type: string required: - isReady - missing_requirements diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/fleet_status_response.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/fleet_status_response.yaml index cf0f0f60084dad..8bb00fdce58d30 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/fleet_status_response.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/fleet_status_response.yaml @@ -18,6 +18,8 @@ properties: type: string enum: - 'encrypted_saved_object_encryption_key_required' + package_verification_key_id: + type: string required: - isReady - missing_requirements diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index e177bcba0ebb25..2148f2ff4555cd 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -406,6 +406,8 @@ export interface IntegrationCardItem { id: string; categories: string[]; fromIntegrations?: string; + isUnverified?: boolean; + showLabels?: boolean; } export type PackageVerificationStatus = 'verified' | 'unverified' | 'unknown'; diff --git a/x-pack/plugins/fleet/common/types/rest_spec/error.ts b/x-pack/plugins/fleet/common/types/rest_spec/error.ts new file mode 100644 index 00000000000000..c02f9cdb4db6ce --- /dev/null +++ b/x-pack/plugins/fleet/common/types/rest_spec/error.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. + */ + +export type FleetErrorType = 'verification_failed'; + +export interface FleetErrorResponse { + message: string; + statusCode: number; + attributes?: { + type?: FleetErrorType; + }; +} diff --git a/x-pack/plugins/fleet/common/types/rest_spec/fleet_setup.ts b/x-pack/plugins/fleet/common/types/rest_spec/fleet_setup.ts index 5204b7bfbdbd10..33929bd92c8b1c 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/fleet_setup.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/fleet_setup.ts @@ -16,4 +16,5 @@ export interface GetFleetStatusResponse { 'security_required' | 'tls_required' | 'api_keys' | 'fleet_admin_user' | 'fleet_server' >; missing_optional_features: Array<'encrypted_saved_object_encryption_key_required'>; + package_verification_key_id?: string; } diff --git a/x-pack/plugins/fleet/common/types/rest_spec/index.ts b/x-pack/plugins/fleet/common/types/rest_spec/index.ts index 78b9f09f7f9f86..989e65209bf9de 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/index.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/index.ts @@ -5,15 +5,16 @@ * 2.0. */ +export * from './agent_policy'; +export * from './agent'; +export * from './app'; export * from './common'; -export * from './package_policy'; export * from './data_stream'; -export * from './agent'; -export * from './agent_policy'; -export * from './fleet_setup'; -export * from './epm'; +export * from './download_sources'; export * from './enrollment_api_key'; +export * from './epm'; +export * from './error'; +export * from './fleet_setup'; export * from './output'; +export * from './package_policy'; export * from './settings'; -export * from './app'; -export * from './download_sources'; diff --git a/x-pack/plugins/fleet/public/applications/integrations/layouts/default.tsx b/x-pack/plugins/fleet/public/applications/integrations/layouts/default.tsx index 2554a8b7309388..24fdfee190512a 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/layouts/default.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/layouts/default.tsx @@ -5,21 +5,61 @@ * 2.0. */ import React, { memo } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiBadge, EuiSpacer, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import styled from 'styled-components'; + import { useLink } from '../../../hooks'; import type { Section } from '../sections'; import { WithHeaderLayout } from '.'; +const TabBadge = styled(EuiBadge)` + padding: 0 1px; + margin-left: 4px; +`; + +const TabTitle: React.FC<{ title: JSX.Element; hasWarning: boolean }> = memo( + ({ title, hasWarning }) => { + return ( + <> + {title} + {hasWarning && } + + ); + } +); interface Props { section?: Section; children?: React.ReactNode; + sectionsWithWarning?: Section[]; } -export const DefaultLayout: React.FunctionComponent = memo(({ section, children }) => { +export const DefaultLayout: React.FC = memo(({ section, children, sectionsWithWarning }) => { const { getHref } = useLink(); + const tabs = [ + { + name: ( + + ), + section: 'browse' as Section, + href: getHref('integrations_all'), + }, + { + name: ( + + ), + section: 'manage' as Section, + href: getHref('integrations_installed'), + }, + ]; return ( = memo(({ section, ch } - tabs={[ - { - name: ( - - ), - isSelected: section === 'browse', - href: getHref('integrations_all'), - }, - { - name: ( - - ), - isSelected: section === 'manage', - href: getHref('integrations_installed'), - }, - ]} + tabs={tabs.map((tab) => ({ + name: ( + + ), + href: tab.href, + isSelected: section === tab.section, + }))} > {children} diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.stories.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.stories.tsx index e62044b31333ac..dee9dfbe4d42bc 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.stories.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.stories.tsx @@ -7,10 +7,6 @@ import React from 'react'; -import type { SavedObject } from '@kbn/core/public'; - -import type { Installation } from '../../../../../../common'; - import type { PackageCardProps } from './package_card'; import { PackageCard } from './package_card'; @@ -19,7 +15,7 @@ export default { description: 'A card representing a package available in Fleet', }; -type Args = Omit & { width: number }; +type Args = PackageCardProps & { width: number }; const args: Args = { width: 280, @@ -33,6 +29,7 @@ const args: Args = { icons: [], integration: '', categories: ['foobar'], + isUnverified: false, }; const argTypes = { @@ -42,48 +39,23 @@ const argTypes = { options: ['ga', 'beta', 'experimental'], }, }, + isUnverified: { + control: 'boolean', + }, }; -export const NotInstalled = ({ width, ...props }: Args) => ( +export const AvailablePackage = ({ width, ...props }: Args) => (
- {/* - // @ts-ignore */} - +
); +AvailablePackage.args = args; +AvailablePackage.argTypes = argTypes; -export const Installed = ({ width, ...props }: Args) => { - const savedObject: SavedObject = { - id: props.id, - // @ts-expect-error - type: props.type || '', - attributes: { - name: props.name, - version: props.version, - install_version: props.version, - es_index_patterns: {}, - installed_kibana: [], - installed_kibana_space_id: 'default', - installed_es: [], - install_status: 'installed', - install_source: 'registry', - install_started_at: '2020-01-01T00:00:00.000Z', - keep_policies_up_to_date: false, - verification_status: 'unknown', - }, - references: [], - }; - - return ( -
- {/* - // @ts-ignore */} - -
- ); -}; - -NotInstalled.args = args; -NotInstalled.argTypes = argTypes; -Installed.args = args; -Installed.argTypes = argTypes; +export const InstalledPackage = ({ width, ...props }: Args) => ( +
+ +
+); +InstalledPackage.args = args; +InstalledPackage.argTypes = argTypes; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx index 16296c928c710e..fa96da7ea6acec 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx @@ -7,10 +7,12 @@ import React from 'react'; import styled from 'styled-components'; -import { EuiCard, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { EuiBadge, EuiCard, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; +import { FormattedMessage } from '@kbn/i18n-react'; + import { CardIcon } from '../../../../../components/package_icon'; import type { IntegrationCardItem } from '../../../../../../common/types/models/epm'; @@ -38,6 +40,8 @@ export function PackageCard({ release, id, fromIntegrations, + isUnverified, + showLabels = true, }: PackageCardProps) { let releaseBadge: React.ReactNode | null = null; @@ -52,6 +56,24 @@ export function PackageCard({ ); } + let verifiedBadge: React.ReactNode | null = null; + + if (isUnverified && showLabels) { + verifiedBadge = ( + + + + + + + + + ); + } + const { application } = useStartServices(); const onCardClick = () => { @@ -88,7 +110,10 @@ export function PackageCard({ } onClick={onCardClick} > - {releaseBadge} + + {verifiedBadge} + {releaseBadge} + ); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.tsx index d9e258f70f9027..09e28e649b84e6 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.tsx @@ -39,6 +39,7 @@ export interface Props { onSearchChange: (search: string) => void; showMissingIntegrationMessage?: boolean; callout?: JSX.Element | null; + showCardLabels?: boolean; } export const PackageListGrid: FunctionComponent = ({ @@ -52,6 +53,7 @@ export const PackageListGrid: FunctionComponent = ({ showMissingIntegrationMessage = false, featuredList = null, callout, + showCardLabels = true, }) => { const [searchTerm, setSearchTerm] = useState(initialSearch || ''); const localSearchRef = useLocalSearch(list); @@ -104,6 +106,7 @@ export const PackageListGrid: FunctionComponent = ({ ); } @@ -180,16 +183,21 @@ function ControlsColumn({ controls, title, sticky }: ControlsColumnProps) { interface GridColumnProps { list: IntegrationCardItem[]; showMissingIntegrationMessage?: boolean; + showCardLabels?: boolean; } -function GridColumn({ list, showMissingIntegrationMessage = false }: GridColumnProps) { +function GridColumn({ + list, + showMissingIntegrationMessage = false, + showCardLabels = false, +}: GridColumnProps) { return ( {list.length ? ( list.map((item) => { return ( - + ); }) diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/overview/overview.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/overview/overview.tsx index 0b477fee2ba77b..5433244d5c2c65 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/overview/overview.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/overview/overview.tsx @@ -6,8 +6,12 @@ */ import React, { memo, useMemo } from 'react'; import styled from 'styled-components'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useFleetStatus } from '../../../../../../../hooks'; +import { isPackageUnverified } from '../../../../../../../services'; import type { PackageInfo, RegistryPolicyTemplate } from '../../../../../types'; import { Screenshots } from './screenshots'; @@ -26,16 +30,39 @@ const LeftColumn = styled(EuiFlexItem)` } `; +const UnverifiedCallout = () => ( + <> + +

+ +

+
+ + +); + export const OverviewPage: React.FC = memo(({ packageInfo, integrationInfo }) => { const screenshots = useMemo( () => integrationInfo?.screenshots || packageInfo.screenshots || [], [integrationInfo, packageInfo.screenshots] ); - + const { packageVerificationKeyId } = useFleetStatus(); + const isUnverified = isPackageUnverified(packageInfo, packageVerificationKeyId); return ( + {isUnverified && } {packageInfo.readme ? ( { // TODO: clintandrewhall - this component is hard to test due to the hooks, particularly those that use `http` // or `location` to load data. Ideally, we'll split this into "connected" and "pure" components. -export const AvailablePackages: React.FC = memo(() => { +export const AvailablePackages: React.FC<{ + allPackages?: GetPackagesResponse | null; + isLoading: boolean; +}> = ({ allPackages, isLoading }) => { const [preference, setPreference] = useState('recommended'); useBreadcrumbs('integrations_all'); @@ -238,7 +241,7 @@ export const AvailablePackages: React.FC = memo(() => { ]; const cards: IntegrationCardItem[] = eprAndCustomPackages.map((item) => { - return mapToCard(getAbsolutePath, getHref, item); + return mapToCard({ getAbsolutePath, getHref, item }); }); cards.sort((a, b) => { @@ -382,6 +385,7 @@ export const AvailablePackages: React.FC = memo(() => { onSearchChange={setSearchTerm} showMissingIntegrationMessage callout={noEprCallout} + showCardLabels={false} /> ); -}); +}; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx index 4322f434ddc707..93d226f8851192 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx @@ -5,21 +5,28 @@ * 2.0. */ -import React, { memo } from 'react'; +import React, { useMemo } from 'react'; import { Switch, Route } from 'react-router-dom'; import type { IntegrationCategory } from '@kbn/custom-integrations-plugin/common'; import type { CustomIntegration } from '@kbn/custom-integrations-plugin/common'; +import { installationStatuses } from '../../../../../../../common/constants'; + import type { DynamicPage, DynamicPagePathValues, StaticPage } from '../../../../constants'; import { INTEGRATIONS_ROUTING_PATHS, INTEGRATIONS_SEARCH_QUERYPARAM } from '../../../../constants'; import { DefaultLayout } from '../../../../layouts'; +import { isPackageUnverified } from '../../../../services'; import type { PackageListItem } from '../../../../types'; import type { IntegrationCardItem } from '../../../../../../../common/types/models'; +import { useGetPackages } from '../../../../hooks'; + +import type { Section } from '../../..'; + import type { CategoryFacet } from './category_facets'; import { InstalledPackages } from './installed_packages'; import { AvailablePackages } from './available_packages'; @@ -43,21 +50,29 @@ export const categoryExists = (category: string, categories: CategoryFacet[]) => return categories.some((c) => c.id === category); }; -export const mapToCard = ( - getAbsolutePath: (p: string) => string, - getHref: (page: StaticPage | DynamicPage, values?: DynamicPagePathValues) => string, - item: CustomIntegration | PackageListItem, - selectedCategory?: string -): IntegrationCardItem => { +export const mapToCard = ({ + getAbsolutePath, + getHref, + item, + packageVerificationKeyId, + selectedCategory, +}: { + getAbsolutePath: (p: string) => string; + getHref: (page: StaticPage | DynamicPage, values?: DynamicPagePathValues) => string; + item: CustomIntegration | PackageListItem; + packageVerificationKeyId?: string; + selectedCategory?: string; +}): IntegrationCardItem => { let uiInternalPathUrl; + let isUnverified = false; if (item.type === 'ui_link') { uiInternalPathUrl = item.uiExternalLink || getAbsolutePath(item.uiInternalPath); } else { let urlVersion = item.version; - if ('savedObject' in item) { urlVersion = item.savedObject.attributes.version || item.version; + isUnverified = isPackageUnverified(item, packageVerificationKeyId); } const url = getHref('integration_details_overview', { @@ -87,22 +102,38 @@ export const mapToCard = ( version: 'version' in item ? item.version || '' : '', release, categories: ((item.categories || []) as string[]).filter((c: string) => !!c), + isUnverified, }; }; -export const EPMHomePage: React.FC = memo(() => { +export const EPMHomePage: React.FC = () => { + const { data: allPackages, isLoading } = useGetPackages({ + experimental: true, + }); + + const installedPackages = useMemo( + () => + (allPackages?.response || []).filter((pkg) => pkg.status === installationStatuses.Installed), + [allPackages?.response] + ); + + const atLeastOneUnverifiedPackageInstalled = installedPackages.some( + (pkg) => 'savedObject' in pkg && pkg.savedObject.attributes.verification_status === 'unverified' + ); + + const sectionsWithWarning = (atLeastOneUnverifiedPackageInstalled ? ['manage'] : []) as Section[]; return ( - - + + - - + + ); -}); +}; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/installed_packages.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/installed_packages.tsx index 19de166cebc169..776aaaafd5a1e5 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/installed_packages.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/installed_packages.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { memo, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { useLocation, useHistory, useParams } from 'react-router-dom'; import semverLt from 'semver/functions/lt'; import { i18n } from '@kbn/i18n'; @@ -13,11 +13,12 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { EuiCallOut, EuiLink } from '@elastic/eui'; -import { installationStatuses } from '../../../../../../../common/constants'; import { pagePathGetters } from '../../../../constants'; -import { useGetPackages, useBreadcrumbs, useLink, useStartServices } from '../../../../hooks'; +import { useBreadcrumbs, useLink, useStartServices, useFleetStatus } from '../../../../hooks'; import { PackageListGrid } from '../../components/package_list_grid'; +import type { PackageListItem } from '../../../../types'; + import type { CategoryFacet } from './category_facets'; import { CategoryFacets } from './category_facets'; @@ -37,7 +38,7 @@ const AnnouncementLink = () => { ); }; -const Callout = () => ( +const InstalledIntegrationsInfoCallout = () => ( ( ); +const VerificationWarningCallout = () => ( + +

+ +

+
+); + // TODO: clintandrewhall - this component is hard to test due to the hooks, particularly those that use `http` // or `location` to load data. Ideally, we'll split this into "connected" and "pure" components. -export const InstalledPackages: React.FC = memo(() => { +export const InstalledPackages: React.FC<{ + installedPackages: PackageListItem[]; + isLoading: boolean; +}> = ({ installedPackages, isLoading }) => { useBreadcrumbs('integrations_installed'); - const { data: allPackages, isLoading } = useGetPackages({ - experimental: true, - }); + const { packageVerificationKeyId } = useFleetStatus(); const { getHref, getAbsolutePath } = useLink(); @@ -93,26 +113,20 @@ export const InstalledPackages: React.FC = memo(() => { ); } - const allInstalledPackages = useMemo( - () => - (allPackages?.response || []).filter((pkg) => pkg.status === installationStatuses.Installed), - [allPackages?.response] - ); - const updatablePackages = useMemo( () => - allInstalledPackages.filter( + installedPackages.filter( (item) => 'savedObject' in item && semverLt(item.savedObject.attributes.version, item.version) ), - [allInstalledPackages] + [installedPackages] ); const categories: CategoryFacet[] = useMemo( () => [ { ...INSTALLED_CATEGORY, - count: allInstalledPackages.length, + count: installedPackages.length, }, { id: 'updates_available', @@ -122,7 +136,7 @@ export const InstalledPackages: React.FC = memo(() => { }), }, ], - [allInstalledPackages.length, updatablePackages.length] + [installedPackages.length, updatablePackages.length] ); if (!categoryExists(selectedCategory, categories)) { @@ -142,10 +156,22 @@ export const InstalledPackages: React.FC = memo(() => { ); const cards = ( - selectedCategory === 'updates_available' ? updatablePackages : allInstalledPackages - ).map((item) => mapToCard(getAbsolutePath, getHref, item, selectedCategory || 'installed')); + selectedCategory === 'updates_available' ? updatablePackages : installedPackages + ).map((item) => + mapToCard({ + getAbsolutePath, + getHref, + item, + selectedCategory: selectedCategory || 'installed', + packageVerificationKeyId, + }) + ); - const callout = selectedCategory === 'updates_available' ? null : ; + const CalloutComponent = cards.some((c) => c.isUnverified) + ? VerificationWarningCallout + : InstalledIntegrationsInfoCallout; + const callout = + selectedCategory === 'updates_available' || isLoading ? null : ; return ( { list={cards} /> ); -}); +}; diff --git a/x-pack/plugins/fleet/public/hooks/use_fleet_status.tsx b/x-pack/plugins/fleet/public/hooks/use_fleet_status.tsx index 7319cd9f07be75..29fa37f3b72704 100644 --- a/x-pack/plugins/fleet/public/hooks/use_fleet_status.tsx +++ b/x-pack/plugins/fleet/public/hooks/use_fleet_status.tsx @@ -19,6 +19,7 @@ interface FleetStatusState { error?: Error; missingRequirements?: GetFleetStatusResponse['missing_requirements']; missingOptionalFeatures?: GetFleetStatusResponse['missing_optional_features']; + packageVerificationKeyId?: GetFleetStatusResponse['package_verification_key_id']; } interface FleetStatus extends FleetStatusState { @@ -58,6 +59,7 @@ export const FleetStatusProvider: React.FC = ({ children }) => { isReady: res.data?.isReady ?? false, missingRequirements: res.data?.missing_requirements, missingOptionalFeatures: res.data?.missing_optional_features, + packageVerificationKeyId: res.data?.package_verification_key_id, })); } catch (error) { setState((s) => ({ ...s, isLoading: false, error })); diff --git a/x-pack/plugins/fleet/public/services/index.ts b/x-pack/plugins/fleet/public/services/index.ts index 2c1bedeaef82c1..094fe2b66c6c90 100644 --- a/x-pack/plugins/fleet/public/services/index.ts +++ b/x-pack/plugins/fleet/public/services/index.ts @@ -41,7 +41,7 @@ export { countValidationErrors, getStreamsForInputType, } from '../../common'; - +export * from './package_verification'; export * from './pkg_key_from_package_info'; export * from './ui_extensions'; export * from './increment_policy_name'; diff --git a/x-pack/plugins/fleet/public/services/package_verification.test.ts b/x-pack/plugins/fleet/public/services/package_verification.test.ts new file mode 100644 index 00000000000000..d611eb6cccf0af --- /dev/null +++ b/x-pack/plugins/fleet/public/services/package_verification.test.ts @@ -0,0 +1,135 @@ +/* + * 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 { PackageVerificationStatus } from '../../common'; +import type { PackageInfo } from '../types'; + +import { ExperimentalFeaturesService, isPackageUnverified } from '.'; + +const mockGet = jest.spyOn(ExperimentalFeaturesService, 'get'); + +const createPackage = ({ + verificationStatus = 'unknown', + verificationKeyId, +}: { + verificationStatus?: PackageVerificationStatus; + verificationKeyId?: string; +} = {}): PackageInfo => ({ + name: 'test-package', + description: 'Test Package', + title: 'Test Package', + version: '0.0.1', + latestVersion: '0.0.1', + release: 'experimental', + format_version: '1.0.0', + owner: { github: 'elastic/fleet' }, + policy_templates: [], + // @ts-ignore + assets: {}, + savedObject: { + id: '1234', + type: 'epm-package', + references: [], + attributes: { + installed_kibana: [], + installed_es: [], + es_index_patterns: {}, + name: 'test-package', + version: '0.0.1', + install_status: 'installed', + install_version: '0.0.1', + install_started_at: new Date().toString(), + install_source: 'registry', + verification_status: verificationStatus, + ...(verificationKeyId && { verification_key_id: verificationKeyId }), + }, + }, +}); + +describe('isPackageUnverified', () => { + describe('When experimental feature is disabled', () => { + beforeEach(() => { + mockGet.mockReturnValue({ + packageVerification: false, + } as ReturnType); + }); + + it('Should return false for a package with no saved object', () => { + const noSoPkg = createPackage(); + // @ts-ignore we know pkg has savedObject but ts doesn't + delete noSoPkg.savedObject; + expect(isPackageUnverified(noSoPkg)).toEqual(false); + }); + it('Should return false for an unverified package', () => { + const unverifiedPkg = createPackage({ verificationStatus: 'unverified' }); + expect(isPackageUnverified(unverifiedPkg)).toEqual(false); + }); + it('Should return false for a verified package', () => { + const verifiedPkg = createPackage({ verificationStatus: 'verified' }); + expect(isPackageUnverified(verifiedPkg)).toEqual(false); + }); + it('Should return false for a verified package with correct key', () => { + const unverifiedPkg = createPackage({ + verificationStatus: 'verified', + verificationKeyId: '1234', + }); + expect(isPackageUnverified(unverifiedPkg, '1234')).toEqual(false); + }); + it('Should return false for a verified package with out of date key', () => { + const unverifiedPkg = createPackage({ + verificationStatus: 'verified', + verificationKeyId: '1234', + }); + expect(isPackageUnverified(unverifiedPkg, 'not_1234')).toEqual(false); + }); + it('Should return false for an unknown verification package', () => { + const unknownPkg = createPackage({ verificationStatus: 'unknown' }); + expect(isPackageUnverified(unknownPkg)).toEqual(false); + }); + }); + describe('When experimental feature is enabled', () => { + beforeEach(() => { + mockGet.mockReturnValue({ + packageVerification: true, + } as ReturnType); + }); + it('Should return false for a package with no saved object', () => { + const noSoPkg = createPackage(); + // @ts-ignore we know pkg has savedObject but ts doesn't + delete noSoPkg.savedObject; + expect(isPackageUnverified(noSoPkg)).toEqual(false); + }); + it('Should return false for a verified package', () => { + const unverifiedPkg = createPackage({ verificationStatus: 'verified' }); + expect(isPackageUnverified(unverifiedPkg)).toEqual(false); + }); + it('Should return false for an unknown verification package', () => { + const unverifiedPkg = createPackage({ verificationStatus: 'unknown' }); + expect(isPackageUnverified(unverifiedPkg)).toEqual(false); + }); + it('Should return false for a verified package with correct key', () => { + const unverifiedPkg = createPackage({ + verificationStatus: 'verified', + verificationKeyId: '1234', + }); + expect(isPackageUnverified(unverifiedPkg, '1234')).toEqual(false); + }); + + it('Should return true for an unverified package', () => { + const unverifiedPkg = createPackage({ verificationStatus: 'unverified' }); + expect(isPackageUnverified(unverifiedPkg)).toEqual(true); + }); + + it('Should return true for a verified package with out of date key', () => { + const unverifiedPkg = createPackage({ + verificationStatus: 'verified', + verificationKeyId: '1234', + }); + expect(isPackageUnverified(unverifiedPkg, 'not_1234')).toEqual(true); + }); + }); +}); diff --git a/x-pack/plugins/fleet/public/services/package_verification.ts b/x-pack/plugins/fleet/public/services/package_verification.ts new file mode 100644 index 00000000000000..5b40a453be4130 --- /dev/null +++ b/x-pack/plugins/fleet/public/services/package_verification.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 type { PackageInfo, PackageListItem } from '../types'; + +import { ExperimentalFeaturesService } from '.'; + +export function isPackageUnverified( + pkg: PackageInfo | PackageListItem, + packageVerificationKeyId?: string +) { + if (!('savedObject' in pkg)) return false; + + const { verification_status: verificationStatus, verification_key_id: verificationKeyId } = + pkg.savedObject.attributes; + + const { packageVerification: isPackageVerificationEnabled } = ExperimentalFeaturesService.get(); + const isKeyOutdated = !!verificationKeyId && verificationKeyId !== packageVerificationKeyId; + const isUnverified = + verificationStatus === 'unverified' || (verificationStatus === 'verified' && isKeyOutdated); + return isPackageVerificationEnabled && isUnverified; +} diff --git a/x-pack/plugins/fleet/server/errors/handlers.ts b/x-pack/plugins/fleet/server/errors/handlers.ts index 1eb0aed4ad690a..8b5f8463c9f41e 100644 --- a/x-pack/plugins/fleet/server/errors/handlers.ts +++ b/x-pack/plugins/fleet/server/errors/handlers.ts @@ -84,7 +84,7 @@ export function ingestErrorToResponseOptions(error: IngestErrorHandlerParams['er logger.error(error.message); return { statusCode: getHTTPResponseCode(error), - body: { message: error.message }, + body: { message: error.message, attributes: error.attributes }, }; } diff --git a/x-pack/plugins/fleet/server/errors/index.ts b/x-pack/plugins/fleet/server/errors/index.ts index cbfcf5733f7364..ecd5bb8c670aee 100644 --- a/x-pack/plugins/fleet/server/errors/index.ts +++ b/x-pack/plugins/fleet/server/errors/index.ts @@ -8,13 +8,15 @@ /* eslint-disable max-classes-per-file */ import type { ElasticsearchErrorDetails } from '@kbn/es-errors'; +import type { FleetErrorType } from '../../common'; + import { isESClientError } from './utils'; export { defaultIngestErrorHandler, ingestErrorToResponseOptions } from './handlers'; export { isESClientError } from './utils'; - export class IngestManagerError extends Error { + attributes?: { type?: FleetErrorType }; constructor(message?: string, public readonly meta?: unknown) { super(message); this.name = this.constructor.name; // for stack traces @@ -34,6 +36,9 @@ export class PackageOutdatedError extends IngestManagerError {} export class PackageFailedVerificationError extends IngestManagerError { constructor(pkgKey: string) { super(`${pkgKey} failed signature verification.`); + this.attributes = { + type: 'verification_failed', + }; } } export class AgentPolicyError extends IngestManagerError {} diff --git a/x-pack/plugins/fleet/server/routes/setup/handlers.ts b/x-pack/plugins/fleet/server/routes/setup/handlers.ts index 59a3516aac83a7..2823c1b3873a55 100644 --- a/x-pack/plugins/fleet/server/routes/setup/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/setup/handlers.ts @@ -11,6 +11,7 @@ import { formatNonFatalErrors, setupFleet } from '../../services/setup'; import { hasFleetServers } from '../../services/fleet_server'; import { defaultIngestErrorHandler } from '../../errors'; import type { FleetRequestHandler } from '../../types'; +import { getGpgKeyIdOrUndefined } from '../../services/epm/packages/package_verification'; export const getFleetStatusHandler: FleetRequestHandler = async (context, request, response) => { try { @@ -43,6 +44,12 @@ export const getFleetStatusHandler: FleetRequestHandler = async (context, reques missing_optional_features: missingOptionalFeatures, }; + const packageVerificationKeyId = await getGpgKeyIdOrUndefined(); + + if (packageVerificationKeyId) { + body.package_verification_key_id = packageVerificationKeyId; + } + return response.ok({ body, }); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/package_verification.ts b/x-pack/plugins/fleet/server/services/epm/packages/package_verification.ts index 0e39cefaf11695..b4432e8919d0cc 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/package_verification.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/package_verification.ts @@ -24,6 +24,14 @@ interface VerificationResult { let cachedKey: openpgp.Key | undefined | null = null; +export async function getGpgKeyIdOrUndefined(): Promise { + const key = await getGpgKeyOrUndefined(); + + if (!key) return undefined; + + return key.getKeyID().toHex(); +} + export async function getGpgKeyOrUndefined(): Promise { if (cachedKey !== null) return cachedKey; From aee38f15f9aff47db76a40cfe50ee29b6a3e3a1a Mon Sep 17 00:00:00 2001 From: Julia Bardi <90178898+juliaElastic@users.noreply.github.com> Date: Fri, 8 Jul 2022 18:49:42 +0200 Subject: [PATCH 20/47] added logic to sanitize tags on UI (#136019) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/tag_options.test.tsx | 4 +- .../components/tag_options.tsx | 5 ++- .../components/tags_add_remove.test.tsx | 40 +++++++++++++++++++ .../components/tags_add_remove.tsx | 27 ++++++++----- .../agents/agent_list_page/utils/index.ts | 1 + .../utils/sanitize_tag.test.ts | 22 ++++++++++ .../agent_list_page/utils/sanitize_tag.ts | 10 +++++ 7 files changed, 96 insertions(+), 13 deletions(-) create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/utils/sanitize_tag.test.ts create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/utils/sanitize_tag.ts diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tag_options.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tag_options.test.tsx index 584a92a60310fa..d4ad6f8fe144fe 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tag_options.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tag_options.test.tsx @@ -57,7 +57,7 @@ describe('TagOptions', () => { fireEvent.click(result.getByText('Delete tag')); expect(mockBulkUpdateTags).toHaveBeenCalledWith( - 'tags:agent', + 'tags:"agent"', [], ['agent'], expect.anything(), @@ -80,7 +80,7 @@ describe('TagOptions', () => { }); expect(mockBulkUpdateTags).toHaveBeenCalledWith( - 'tags:agent', + 'tags:"agent"', ['newName'], ['agent'], expect.anything(), diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tag_options.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tag_options.tsx index 994fb1b64880ec..7e25a7a098dba4 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tag_options.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tag_options.tsx @@ -20,6 +20,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { useUpdateTags } from '../hooks'; +import { sanitizeTag } from '../utils'; interface Props { tagName: string; @@ -53,7 +54,7 @@ export const TagOptions: React.FC = ({ tagName, isTagHovered, onTagsUpdat const updateTagsHook = useUpdateTags(); const bulkUpdateTags = updateTagsHook.bulkUpdateTags; - const TAGS_QUERY = 'tags:{name}'; + const TAGS_QUERY = 'tags:"{name}"'; const handleRename = (newName?: string) => { if (newName === tagName || !newName) { @@ -127,7 +128,7 @@ export const TagOptions: React.FC = ({ tagName, isTagHovered, onTagsUpdat }} onChange={(e: ChangeEvent) => { const newName = e.currentTarget.value; - setUpdatedName(newName); + setUpdatedName(sanitizeTag(newName)); }} />
diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags_add_remove.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags_add_remove.test.tsx index 4467f8413d5833..75e829d12b92ee 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags_add_remove.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags_add_remove.test.tsx @@ -103,6 +103,46 @@ describe('TagsAddRemove', () => { ); }); + it('should add new tag by removing special chars', () => { + const result = renderComponent('agent1'); + const searchInput = result.getByRole('combobox'); + + fireEvent.input(searchInput, { + target: { value: 'Tag-123: _myTag"' }, + }); + + fireEvent.click(result.getAllByText('Create a new tag "Tag-123 _myTag"')[0].closest('button')!); + + expect(mockUpdateTags).toHaveBeenCalledWith( + 'agent1', + ['tag1', 'Tag-123 _myTag'], + expect.anything(), + 'Tag created', + 'Tag creation failed' + ); + }); + + it('should limit new tag to 20 length', () => { + const result = renderComponent('agent1'); + const searchInput = result.getByRole('combobox'); + + fireEvent.input(searchInput, { + target: { value: '01234567890123456789123' }, + }); + + fireEvent.click( + result.getAllByText('Create a new tag "01234567890123456789"')[0].closest('button')! + ); + + expect(mockUpdateTags).toHaveBeenCalledWith( + 'agent1', + ['tag1', '01234567890123456789'], + expect.anything(), + 'Tag created', + 'Tag creation failed' + ); + }); + it('should add selected tag when previously unselected - bulk selection', async () => { mockBulkUpdateTags.mockImplementation(() => { selectedTags = ['tag1', 'tag2']; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags_add_remove.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags_add_remove.tsx index c60c6cec19fd94..c52ad66f2e9d02 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags_add_remove.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags_add_remove.tsx @@ -22,6 +22,8 @@ import { i18n } from '@kbn/i18n'; import { useUpdateTags } from '../hooks'; +import { sanitizeTag } from '../utils'; + import { TagOptions } from './tag_options'; interface Props { @@ -138,8 +140,9 @@ export const TagsAddRemove: React.FC = ({ defaultMessage: 'Find or create label...', }), onChange: (value: string) => { - setSearchValue(value); + setSearchValue(sanitizeTag(value)); }, + value: searchValue ?? '', }} options={labels} renderOption={renderOption} @@ -162,14 +165,20 @@ export const TagsAddRemove: React.FC = ({ ); }} > - {' '} - + + + + + + + + } > diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/utils/index.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/utils/index.ts index 192b4a5593b349..a269d6a68c6899 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/utils/index.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/utils/index.ts @@ -7,3 +7,4 @@ export * from './truncate_tag'; export * from './get_common_tags'; +export * from './sanitize_tag'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/utils/sanitize_tag.test.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/utils/sanitize_tag.test.ts new file mode 100644 index 00000000000000..8e07fd8e04b99c --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/utils/sanitize_tag.test.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { sanitizeTag } from './sanitize_tag'; + +describe('sanitizeTag', () => { + it('should remove special characters from tag name', () => { + expect(sanitizeTag('Tag-123: []"\'#$%^&*__')).toEqual('Tag-123 __'); + }); + + it('should limit tag to 20 length', () => { + expect(sanitizeTag('aaaa aaaa aaaa aaaa bbb')).toEqual('aaaa aaaa aaaa aaaa '); + }); + + it('should do nothing for empty tag', () => { + expect(sanitizeTag('')).toEqual(''); + }); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/utils/sanitize_tag.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/utils/sanitize_tag.ts new file mode 100644 index 00000000000000..d0b00ea9bed194 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/utils/sanitize_tag.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. + */ + +export function sanitizeTag(tag: string): string { + return tag.replace(/[^a-zA-Z0-9 \-_]/g, '').slice(0, 20); +} From 6c08eacdc0cf23a9ec645afec300b926cc2028ba Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Fri, 8 Jul 2022 12:57:16 -0400 Subject: [PATCH 21/47] [File Upload] [Maps] Reduce precision of coordinates for geo imports (#135133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Reduce precision of coordinates for geo imports * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * Revert to jsts@1.6.2 The jsts library does not transpile modules since 2.0. So it is not currently possible to use the newer library. * Fix yarn lockfile * Fix test Test runs on features, not feature collections. 😬 Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../geo/geojson_clean_and_validate.js | 11 +++++- .../geo/geojson_clean_and_validate.test.js | 38 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/file_upload/public/importer/geo/geojson_clean_and_validate.js b/x-pack/plugins/file_upload/public/importer/geo/geojson_clean_and_validate.js index 17b4f94b52d5f3..e16374d851de89 100644 --- a/x-pack/plugins/file_upload/public/importer/geo/geojson_clean_and_validate.js +++ b/x-pack/plugins/file_upload/public/importer/geo/geojson_clean_and_validate.js @@ -8,6 +8,13 @@ import * as jsts from 'jsts'; import rewind from '@mapbox/geojson-rewind'; +// The GeoJSON specification suggests limiting coordinate precision to six decimal places +// See https://datatracker.ietf.org/doc/html/rfc7946#section-11.2 +// We can enforce rounding to six decimal places by setting the PrecisionModel scale +// scale = 10^n where n = maximum number of decimal places +const precisionModel = new jsts.geom.PrecisionModel(Math.pow(10, 6)); +const geometryPrecisionReducer = new jsts.precision.GeometryPrecisionReducer(precisionModel); +geometryPrecisionReducer.setChangePrecisionModel(true); const geoJSONReader = new jsts.io.GeoJSONReader(); const geoJSONWriter = new jsts.io.GeoJSONWriter(); @@ -36,6 +43,8 @@ export function cleanGeometry({ geometry }) { if (!geometry) { return null; } - const geometryToWrite = geometry.isSimple() || geometry.isValid() ? geometry : geometry.buffer(0); + + // GeometryPrecisionReducer will automatically clean invalid geometries + const geometryToWrite = geometryPrecisionReducer.reduce(geometry); return geoJSONWriter.write(geometryToWrite); } diff --git a/x-pack/plugins/file_upload/public/importer/geo/geojson_clean_and_validate.test.js b/x-pack/plugins/file_upload/public/importer/geo/geojson_clean_and_validate.test.js index 0f8d126251dfba..8c9000e66e8118 100644 --- a/x-pack/plugins/file_upload/public/importer/geo/geojson_clean_and_validate.test.js +++ b/x-pack/plugins/file_upload/public/importer/geo/geojson_clean_and_validate.test.js @@ -102,6 +102,44 @@ describe('geo_json_clean_and_validate', () => { }); }); + it('should reduce coordinate precision', () => { + const ludicrousPrecisionGeoJson = { + type: 'Feature', + properties: {}, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [108.28125, 61.77312286453146], + [72.0703125, 46.31658418182218], + [99.49218749999999, 22.917922936146045], + [133.2421875, 27.059125784374068], + [139.5703125, 52.908902047770255], + [108.28125, 61.77312286453146], + ], + ], + }, + }; + + expect(geoJsonCleanAndValidate(ludicrousPrecisionGeoJson)).toEqual({ + type: 'Feature', + properties: {}, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [108.28125, 61.773123], + [72.070313, 46.316584], + [99.492187, 22.917923], + [133.242188, 27.059126], + [139.570313, 52.908902], + [108.28125, 61.773123], + ], + ], + }, + }); + }); + it('should reverse counter-clockwise winding order', () => { const counterClockwiseGeoJson = { type: 'Feature', From 87ac0fd2feb30f6d659a9aa023f816fdb0fd9b3a Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Fri, 8 Jul 2022 12:02:59 -0500 Subject: [PATCH 22/47] [docker] Add ubi9 image (#135868) * [docker] Add ubi9 image * update artifacts tests * cleanup * fixes * formatting --- .buildkite/pipelines/artifacts.yml | 12 +++++++++++- .buildkite/pipelines/docker_context.yml | 11 ----------- .../scripts/steps/artifacts/docker_context.sh | 4 +++- .../tasks/os_packages/create_os_package_tasks.ts | 16 +++++++++++++--- .../tasks/os_packages/docker_generator/run.ts | 9 +++++---- .../docker_generator/template_context.ts | 2 +- .../templates/dockerfile.template.ts | 4 ++-- 7 files changed, 35 insertions(+), 23 deletions(-) delete mode 100644 .buildkite/pipelines/docker_context.yml diff --git a/.buildkite/pipelines/artifacts.yml b/.buildkite/pipelines/artifacts.yml index bfe5fe190ea16a..8e08c736694e83 100644 --- a/.buildkite/pipelines/artifacts.yml +++ b/.buildkite/pipelines/artifacts.yml @@ -51,7 +51,17 @@ steps: - exit_status: '*' limit: 1 - - command: KIBANA_DOCKER_CONTEXT=ubi .buildkite/scripts/steps/artifacts/docker_context.sh + - command: KIBANA_DOCKER_CONTEXT=ubi8 .buildkite/scripts/steps/artifacts/docker_context.sh + label: 'Docker Context Verification' + agents: + queue: n2-2 + timeout_in_minutes: 30 + retry: + automatic: + - exit_status: '*' + limit: 1 + + - command: KIBANA_DOCKER_CONTEXT=ubi9 .buildkite/scripts/steps/artifacts/docker_context.sh label: 'Docker Context Verification' agents: queue: n2-2 diff --git a/.buildkite/pipelines/docker_context.yml b/.buildkite/pipelines/docker_context.yml deleted file mode 100644 index f85b895e4780b0..00000000000000 --- a/.buildkite/pipelines/docker_context.yml +++ /dev/null @@ -1,11 +0,0 @@ - steps: - - command: .buildkite/scripts/steps/docker_context/build.sh - label: 'Docker Build Context' - agents: - queue: n2-4 - timeout_in_minutes: 30 - key: build-docker-context - retry: - automatic: - - exit_status: '*' - limit: 1 \ No newline at end of file diff --git a/.buildkite/scripts/steps/artifacts/docker_context.sh b/.buildkite/scripts/steps/artifacts/docker_context.sh index 1195d7ad5dc381..86c4361173a080 100755 --- a/.buildkite/scripts/steps/artifacts/docker_context.sh +++ b/.buildkite/scripts/steps/artifacts/docker_context.sh @@ -19,8 +19,10 @@ if [[ "$KIBANA_DOCKER_CONTEXT" == "default" ]]; then DOCKER_CONTEXT_FILE="kibana-$FULL_VERSION-docker-build-context.tar.gz" elif [[ "$KIBANA_DOCKER_CONTEXT" == "cloud" ]]; then DOCKER_CONTEXT_FILE="kibana-cloud-$FULL_VERSION-docker-build-context.tar.gz" -elif [[ "$KIBANA_DOCKER_CONTEXT" == "ubi" ]]; then +elif [[ "$KIBANA_DOCKER_CONTEXT" == "ubi8" ]]; then DOCKER_CONTEXT_FILE="kibana-ubi8-$FULL_VERSION-docker-build-context.tar.gz" +elif [[ "$KIBANA_DOCKER_CONTEXT" == "ubi9" ]]; then + DOCKER_CONTEXT_FILE="kibana-ubi9-$FULL_VERSION-docker-build-context.tar.gz" fi tar -xf "target/$DOCKER_CONTEXT_FILE" -C "$DOCKER_BUILD_FOLDER" diff --git a/src/dev/build/tasks/os_packages/create_os_package_tasks.ts b/src/dev/build/tasks/os_packages/create_os_package_tasks.ts index 49967feb214d6f..69a272d39f4a07 100644 --- a/src/dev/build/tasks/os_packages/create_os_package_tasks.ts +++ b/src/dev/build/tasks/os_packages/create_os_package_tasks.ts @@ -86,7 +86,13 @@ export const CreateDockerUBI: Task = { async run(config, log, build) { await runDockerGenerator(config, log, build, { architecture: 'x64', - baseImage: 'ubi', + baseImage: 'ubi8', + context: false, + image: true, + }); + await runDockerGenerator(config, log, build, { + architecture: 'x64', + baseImage: 'ubi9', context: false, image: true, }); @@ -124,9 +130,13 @@ export const CreateDockerContexts: Task = { image: false, dockerBuildDate, }); - await runDockerGenerator(config, log, build, { - baseImage: 'ubi', + baseImage: 'ubi8', + context: true, + image: false, + }); + await runDockerGenerator(config, log, build, { + baseImage: 'ubi9', context: true, image: false, }); diff --git a/src/dev/build/tasks/os_packages/docker_generator/run.ts b/src/dev/build/tasks/os_packages/docker_generator/run.ts index d8b604f00b46ec..34b58e7513bb16 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/run.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/run.ts @@ -29,7 +29,7 @@ export async function runDockerGenerator( build: Build, flags: { architecture?: string; - baseImage: 'none' | 'ubi' | 'ubuntu'; + baseImage: 'none' | 'ubi9' | 'ubi8' | 'ubuntu'; context: boolean; image: boolean; ironbank?: boolean; @@ -39,11 +39,12 @@ export async function runDockerGenerator( ) { let baseImageName = ''; if (flags.baseImage === 'ubuntu') baseImageName = 'ubuntu:20.04'; - if (flags.baseImage === 'ubi') baseImageName = 'docker.elastic.co/ubi8/ubi-minimal:latest'; - const ubiVersionTag = 'ubi8'; + if (flags.baseImage === 'ubi8') baseImageName = 'docker.elastic.co/ubi8/ubi-minimal:latest'; + if (flags.baseImage === 'ubi9') baseImageName = 'docker.elastic.co/ubi9/ubi-minimal:latest'; let imageFlavor = ''; - if (flags.baseImage === 'ubi') imageFlavor += `-${ubiVersionTag}`; + if (flags.baseImage === 'ubi8') imageFlavor += `-ubi8`; + if (flags.baseImage === 'ubi9') imageFlavor += `-ubi9`; if (flags.ironbank) imageFlavor += '-ironbank'; if (flags.cloud) imageFlavor += '-cloud'; diff --git a/src/dev/build/tasks/os_packages/docker_generator/template_context.ts b/src/dev/build/tasks/os_packages/docker_generator/template_context.ts index 32a551820a05b5..da2d7422a03ea7 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/template_context.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/template_context.ts @@ -23,7 +23,7 @@ export interface TemplateContext { dockerBuildDate: string; usePublicArtifact?: boolean; publicArtifactSubdomain: string; - baseImage: 'none' | 'ubi' | 'ubuntu'; + baseImage: 'none' | 'ubi8' | 'ubi9' | 'ubuntu'; baseImageName: string; cloud?: boolean; metricbeatTarball?: string; diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts b/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts index 63b04ed6f70b03..ca597e5c38941f 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts @@ -16,8 +16,8 @@ function generator(options: TemplateContext) { const dir = options.ironbank ? 'ironbank' : 'base'; const template = readFileSync(resolve(__dirname, dir, './Dockerfile')); return Mustache.render(template.toString(), { - packageManager: options.baseImage === 'ubi' ? 'microdnf' : 'apt-get', - ubi: options.baseImage === 'ubi', + packageManager: options.baseImage.includes('ubi') ? 'microdnf' : 'apt-get', + ubi: options.baseImage.includes('ubi'), ubuntu: options.baseImage === 'ubuntu', ...options, }); From 3891aeb95fb030348807dee583e0c217a9083b7d Mon Sep 17 00:00:00 2001 From: Andrew Tate Date: Fri, 8 Jul 2022 12:07:43 -0500 Subject: [PATCH 23/47] [Chart expressions] new metric vis expression (#135461) --- .i18nrc.json | 15 +- docs/developer/plugin-list.asciidoc | 4 + packages/kbn-optimizer/limits.yml | 1 + .../expression_legacy_metric/.i18nrc.json | 6 + .../.storybook/main.js | 26 + .../expression_legacy_metric/README.md | 9 + .../common/constants.ts | 14 + .../metric_vis_function.test.ts.snap | 2 +- .../common/expression_functions/index.ts | 9 + .../metric_vis_function.test.ts | 0 .../metric_vis_function.ts | 206 ++++ .../expression_legacy_metric/common/index.ts | 25 + .../common/types/expression_functions.ts | 48 + .../common/types/expression_renderers.ts | 59 ++ .../common/types/index.ts | 10 + .../expression_legacy_metric/jest.config.js | 19 + .../expression_legacy_metric/kibana.json | 21 + .../public/__mocks__/format_service.ts | 0 .../public/__mocks__/palette_service.ts | 0 .../public/__mocks__/services.ts | 0 .../__stories__/metric_renderer.stories.tsx | 0 .../metric_component.test.tsx.snap | 2 +- .../with_auto_scale.test.tsx.snap | 0 .../public/components/metric.scss | 17 +- .../components/metric_component.test.tsx | 0 .../public/components/metric_component.tsx | 0 .../public/components/metric_value.test.tsx | 6 +- .../public/components/metric_value.tsx | 12 +- .../components/with_auto_scale.styles.ts | 0 .../components/with_auto_scale.test.tsx | 0 .../public/components/with_auto_scale.tsx | 0 .../public/expression_renderers/index.ts | 9 + .../metric_vis_renderer.tsx | 91 ++ .../public/format_service.ts | 0 .../expression_legacy_metric/public/index.ts | 13 + .../expression_legacy_metric/public/plugin.ts | 41 + .../public/services/format_service.ts | 13 + .../public/services/index.ts | 10 + .../public/services/palette_service.ts | 13 + .../public/utils/format.ts | 21 + .../public/utils/index.ts | 9 + .../public/utils/palette.ts | 41 + .../expression_legacy_metric/server/index.ts | 13 + .../expression_legacy_metric/server/plugin.ts | 40 + .../expression_legacy_metric/tsconfig.json | 23 + .../metric_vis_function.ts | 197 ++-- .../common/types/expression_functions.ts | 25 +- .../common/types/expression_renderers.ts | 34 +- .../expression_metric/kibana.json | 10 +- .../public/__mocks__/theme_service.ts | 13 + .../public/components/currency_codes.test.ts | 33 + .../public/components/currency_codes.ts | 46 + .../public/components/metric_vis.test.tsx | 905 ++++++++++++++++++ .../public/components/metric_vis.tsx | 240 +++++ .../metric_vis_renderer.tsx | 62 +- .../expression_metric/public/plugin.ts | 12 +- .../public/services/index.ts | 2 + .../public/services/theme_service.ts | 13 + .../public/services/ui_settings.ts | 13 + .../common/constants/base_formatters.ts | 2 + .../common/converters/currency.test.ts | 31 + .../common/converters/currency.ts | 23 + .../field_formats/common/converters/index.ts | 1 + src/plugins/field_formats/common/types.ts | 1 + .../public/__snapshots__/to_ast.test.ts.snap | 4 +- src/plugins/vis_types/metric/public/to_ast.ts | 2 +- .../page_objects/visualize_chart_page.ts | 2 +- .../page_objects/visualize_editor_page.ts | 2 +- .../services/dashboard/expectations.ts | 2 +- .../snapshots/baseline/combined_test3.json | 2 +- .../snapshots/baseline/final_output_test.json | 2 +- .../snapshots/baseline/metric_all_data.json | 2 +- .../snapshots/baseline/metric_empty_data.json | 2 +- .../baseline/metric_invalid_data.json | 2 +- .../baseline/metric_multi_metric_data.json | 2 +- .../baseline/metric_percentage_mode.json | 2 +- .../baseline/metric_single_metric_data.json | 2 +- .../snapshots/baseline/partial_test_2.json | 2 +- .../snapshots/baseline/step_output_test3.json | 2 +- .../snapshots/session/combined_test3.json | 2 +- .../snapshots/session/final_output_test.json | 2 +- .../snapshots/session/metric_all_data.json | 2 +- .../snapshots/session/metric_empty_data.json | 2 +- .../session/metric_multi_metric_data.json | 2 +- .../session/metric_percentage_mode.json | 2 +- .../session/metric_single_metric_data.json | 2 +- .../snapshots/session/partial_test_2.json | 2 +- .../snapshots/session/step_output_test3.json | 2 +- .../test_suites/run_pipeline/basic.ts | 4 +- .../test_suites/run_pipeline/metric.ts | 14 +- tsconfig.base.json | 2 + .../canvas_plugin_src/elements/index.ts | 3 +- .../elements/metric_vis/index.ts | 12 +- .../elements/metric_vis_legacy/index.ts | 21 + .../uis/models/metric_vis.ts | 2 +- .../i18n/elements/element_strings.test.ts | 4 +- .../canvas/i18n/elements/element_strings.ts | 10 +- .../visualization.test.ts | 2 +- .../metric_visualization/visualization.tsx | 2 +- .../translations/translations/fr-FR.json | 12 - .../translations/translations/ja-JP.json | 12 - .../translations/translations/zh-CN.json | 12 - .../time_to_visualize_security.ts | 2 +- .../apps/lens/group1/persistent_context.ts | 4 +- .../apps/lens/group2/add_to_dashboard.ts | 12 +- .../apps/lens/group2/epoch_millis.ts | 4 +- .../functional/apps/lens/group3/chart_data.ts | 2 +- .../functional/apps/lens/group3/formula.ts | 2 +- .../functional/apps/lens/group3/metrics.ts | 6 +- .../functional/apps/lens/group3/rollup.ts | 4 +- .../apps/lens/group3/tsvb_open_in_lens.ts | 2 +- .../data_visualizer/index_data_visualizer.ts | 4 +- 112 files changed, 2372 insertions(+), 343 deletions(-) create mode 100755 src/plugins/chart_expressions/expression_legacy_metric/.i18nrc.json create mode 100644 src/plugins/chart_expressions/expression_legacy_metric/.storybook/main.js create mode 100755 src/plugins/chart_expressions/expression_legacy_metric/README.md create mode 100644 src/plugins/chart_expressions/expression_legacy_metric/common/constants.ts rename src/plugins/chart_expressions/{expression_metric => expression_legacy_metric}/common/expression_functions/__snapshots__/metric_vis_function.test.ts.snap (99%) create mode 100644 src/plugins/chart_expressions/expression_legacy_metric/common/expression_functions/index.ts rename src/plugins/chart_expressions/{expression_metric => expression_legacy_metric}/common/expression_functions/metric_vis_function.test.ts (100%) create mode 100644 src/plugins/chart_expressions/expression_legacy_metric/common/expression_functions/metric_vis_function.ts create mode 100755 src/plugins/chart_expressions/expression_legacy_metric/common/index.ts create mode 100644 src/plugins/chart_expressions/expression_legacy_metric/common/types/expression_functions.ts create mode 100644 src/plugins/chart_expressions/expression_legacy_metric/common/types/expression_renderers.ts create mode 100644 src/plugins/chart_expressions/expression_legacy_metric/common/types/index.ts create mode 100644 src/plugins/chart_expressions/expression_legacy_metric/jest.config.js create mode 100755 src/plugins/chart_expressions/expression_legacy_metric/kibana.json rename src/plugins/chart_expressions/{expression_metric => expression_legacy_metric}/public/__mocks__/format_service.ts (100%) rename src/plugins/chart_expressions/{expression_metric => expression_legacy_metric}/public/__mocks__/palette_service.ts (100%) rename src/plugins/chart_expressions/{expression_metric => expression_legacy_metric}/public/__mocks__/services.ts (100%) rename src/plugins/chart_expressions/{expression_metric => expression_legacy_metric}/public/__stories__/metric_renderer.stories.tsx (100%) rename src/plugins/chart_expressions/{expression_metric => expression_legacy_metric}/public/components/__snapshots__/metric_component.test.tsx.snap (99%) rename src/plugins/chart_expressions/{expression_metric => expression_legacy_metric}/public/components/__snapshots__/with_auto_scale.test.tsx.snap (100%) rename src/plugins/chart_expressions/{expression_metric => expression_legacy_metric}/public/components/metric.scss (81%) rename src/plugins/chart_expressions/{expression_metric => expression_legacy_metric}/public/components/metric_component.test.tsx (100%) rename src/plugins/chart_expressions/{expression_metric => expression_legacy_metric}/public/components/metric_component.tsx (100%) rename src/plugins/chart_expressions/{expression_metric => expression_legacy_metric}/public/components/metric_value.test.tsx (92%) rename src/plugins/chart_expressions/{expression_metric => expression_legacy_metric}/public/components/metric_value.tsx (88%) rename src/plugins/chart_expressions/{expression_metric => expression_legacy_metric}/public/components/with_auto_scale.styles.ts (100%) rename src/plugins/chart_expressions/{expression_metric => expression_legacy_metric}/public/components/with_auto_scale.test.tsx (100%) rename src/plugins/chart_expressions/{expression_metric => expression_legacy_metric}/public/components/with_auto_scale.tsx (100%) create mode 100644 src/plugins/chart_expressions/expression_legacy_metric/public/expression_renderers/index.ts create mode 100644 src/plugins/chart_expressions/expression_legacy_metric/public/expression_renderers/metric_vis_renderer.tsx rename src/plugins/chart_expressions/{expression_metric => expression_legacy_metric}/public/format_service.ts (100%) create mode 100644 src/plugins/chart_expressions/expression_legacy_metric/public/index.ts create mode 100644 src/plugins/chart_expressions/expression_legacy_metric/public/plugin.ts create mode 100644 src/plugins/chart_expressions/expression_legacy_metric/public/services/format_service.ts create mode 100644 src/plugins/chart_expressions/expression_legacy_metric/public/services/index.ts create mode 100644 src/plugins/chart_expressions/expression_legacy_metric/public/services/palette_service.ts create mode 100644 src/plugins/chart_expressions/expression_legacy_metric/public/utils/format.ts create mode 100644 src/plugins/chart_expressions/expression_legacy_metric/public/utils/index.ts create mode 100644 src/plugins/chart_expressions/expression_legacy_metric/public/utils/palette.ts create mode 100644 src/plugins/chart_expressions/expression_legacy_metric/server/index.ts create mode 100644 src/plugins/chart_expressions/expression_legacy_metric/server/plugin.ts create mode 100644 src/plugins/chart_expressions/expression_legacy_metric/tsconfig.json create mode 100644 src/plugins/chart_expressions/expression_metric/public/__mocks__/theme_service.ts create mode 100644 src/plugins/chart_expressions/expression_metric/public/components/currency_codes.test.ts create mode 100644 src/plugins/chart_expressions/expression_metric/public/components/currency_codes.ts create mode 100644 src/plugins/chart_expressions/expression_metric/public/components/metric_vis.test.tsx create mode 100644 src/plugins/chart_expressions/expression_metric/public/components/metric_vis.tsx create mode 100644 src/plugins/chart_expressions/expression_metric/public/services/theme_service.ts create mode 100644 src/plugins/chart_expressions/expression_metric/public/services/ui_settings.ts create mode 100644 src/plugins/field_formats/common/converters/currency.test.ts create mode 100644 src/plugins/field_formats/common/converters/currency.ts create mode 100644 x-pack/plugins/canvas/canvas_plugin_src/elements/metric_vis_legacy/index.ts diff --git a/.i18nrc.json b/.i18nrc.json index 412d16930c9ac4..073a413fabf804 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -29,6 +29,7 @@ "expressionImage": "src/plugins/expression_image", "expressionMetric": "src/plugins/expression_metric", "expressionMetricVis": "src/plugins/chart_expressions/expression_metric", + "expressionLegacyMetricVis": "src/plugins/chart_expressions/expression_legacy_metric", "expressionPartitionVis": "src/plugins/chart_expressions/expression_partition_vis", "expressionXY": "src/plugins/chart_expressions/expression_xy", "expressionRepeatImage": "src/plugins/expression_repeat_image", @@ -57,10 +58,7 @@ "kibana-react": "src/plugins/kibana_react", "kibanaOverview": "src/plugins/kibana_overview", "lists": "packages/kbn-securitysolution-list-utils/src", - "management": [ - "src/legacy/core_plugins/management", - "src/plugins/management" - ], + "management": ["src/legacy/core_plugins/management", "src/plugins/management"], "monaco": "packages/kbn-monaco/src", "navigation": "src/plugins/navigation", "newsfeed": "src/plugins/newsfeed", @@ -74,13 +72,8 @@ "sharedUXPackages": "packages/shared-ux", "coloring": "packages/kbn-coloring/src", "statusPage": "src/legacy/core_plugins/status_page", - "telemetry": [ - "src/plugins/telemetry", - "src/plugins/telemetry_management_section" - ], - "timelion": [ - "src/plugins/vis_types/timelion" - ], + "telemetry": ["src/plugins/telemetry", "src/plugins/telemetry_management_section"], + "timelion": ["src/plugins/vis_types/timelion"], "uiActions": "src/plugins/ui_actions", "uiActionsEnhanced": "src/plugins/ui_actions_enhanced", "uiActionsExamples": "examples/ui_action_examples", diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index c939ab2dcf690a..041c0cee573592 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -114,6 +114,10 @@ This API doesn't support angular, for registering angular dev tools, bootstrap a |Expression Image plugin adds an image renderer to the expression plugin. The renderer will display the given image. +|{kib-repo}blob/{branch}/src/plugins/chart_expressions/expression_legacy_metric/README.md[expressionLegacyMetricVis] +|Expression MetricVis plugin adds a metric renderer and function to the expression plugin. The renderer will display the metric chart. + + |{kib-repo}blob/{branch}/src/plugins/expression_metric/README.md[expressionMetric] |Expression Metric plugin adds a metric renderer and function to the expression plugin. diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 0dafe3c51b77ea..0dc1e1ee4675e6 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -96,6 +96,7 @@ pageLoadAssetSize: securitySolution: 273763 customIntegrations: 28810 expressionMetricVis: 23121 + expressionLegacyMetricVis: 23121 expressionHeatmap: 27505 visTypeMetric: 23332 bfetch: 22837 diff --git a/src/plugins/chart_expressions/expression_legacy_metric/.i18nrc.json b/src/plugins/chart_expressions/expression_legacy_metric/.i18nrc.json new file mode 100755 index 00000000000000..28e2d09a1a433e --- /dev/null +++ b/src/plugins/chart_expressions/expression_legacy_metric/.i18nrc.json @@ -0,0 +1,6 @@ +{ + "prefix": "expressionLegacyMetricVis", + "paths": { + "expressionLegacyMetricVis": "." + } +} diff --git a/src/plugins/chart_expressions/expression_legacy_metric/.storybook/main.js b/src/plugins/chart_expressions/expression_legacy_metric/.storybook/main.js new file mode 100644 index 00000000000000..80e65c9e371f0b --- /dev/null +++ b/src/plugins/chart_expressions/expression_legacy_metric/.storybook/main.js @@ -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 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 { defaultConfig } from '@kbn/storybook'; +import webpackMerge from 'webpack-merge'; +import { resolve } from 'path'; + +const mockConfig = { + resolve: { + alias: { + '../../../expression_legacy_metric/public/services': resolve( + __dirname, + '../public/__mocks__/services.ts' + ), + }, + }, +}; + +module.exports = { + ...defaultConfig, + webpackFinal: (config) => webpackMerge(config, mockConfig), +}; diff --git a/src/plugins/chart_expressions/expression_legacy_metric/README.md b/src/plugins/chart_expressions/expression_legacy_metric/README.md new file mode 100755 index 00000000000000..07b830feae67d4 --- /dev/null +++ b/src/plugins/chart_expressions/expression_legacy_metric/README.md @@ -0,0 +1,9 @@ +# expressionLegacyMetricVis + +Expression MetricVis plugin adds a `metric` renderer and function to the expression plugin. The renderer will display the `metric` chart. + +--- + +## Development + +See the [kibana contributing guide](https://github.com/elastic/kibana/blob/main/CONTRIBUTING.md) for instructions setting up your development environment. diff --git a/src/plugins/chart_expressions/expression_legacy_metric/common/constants.ts b/src/plugins/chart_expressions/expression_legacy_metric/common/constants.ts new file mode 100644 index 00000000000000..54cfe41f2a3b7a --- /dev/null +++ b/src/plugins/chart_expressions/expression_legacy_metric/common/constants.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 const EXPRESSION_METRIC_NAME = 'legacyMetricVis'; + +export const LabelPosition = { + BOTTOM: 'bottom', + TOP: 'top', +} as const; diff --git a/src/plugins/chart_expressions/expression_metric/common/expression_functions/__snapshots__/metric_vis_function.test.ts.snap b/src/plugins/chart_expressions/expression_legacy_metric/common/expression_functions/__snapshots__/metric_vis_function.test.ts.snap similarity index 99% rename from src/plugins/chart_expressions/expression_metric/common/expression_functions/__snapshots__/metric_vis_function.test.ts.snap rename to src/plugins/chart_expressions/expression_legacy_metric/common/expression_functions/__snapshots__/metric_vis_function.test.ts.snap index 44cc3fee09b1fa..9a7a7d5a5035c7 100644 --- a/src/plugins/chart_expressions/expression_metric/common/expression_functions/__snapshots__/metric_vis_function.test.ts.snap +++ b/src/plugins/chart_expressions/expression_legacy_metric/common/expression_functions/__snapshots__/metric_vis_function.test.ts.snap @@ -23,7 +23,7 @@ Object { exports[`interpreter/functions#metric returns an object with the correct structure 1`] = ` Object { - "as": "metricVis", + "as": "legacyMetricVis", "type": "render", "value": Object { "visConfig": Object { diff --git a/src/plugins/chart_expressions/expression_legacy_metric/common/expression_functions/index.ts b/src/plugins/chart_expressions/expression_legacy_metric/common/expression_functions/index.ts new file mode 100644 index 00000000000000..5eccaa62fe4645 --- /dev/null +++ b/src/plugins/chart_expressions/expression_legacy_metric/common/expression_functions/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 { metricVisFunction } from './metric_vis_function'; diff --git a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.test.ts b/src/plugins/chart_expressions/expression_legacy_metric/common/expression_functions/metric_vis_function.test.ts similarity index 100% rename from src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.test.ts rename to src/plugins/chart_expressions/expression_legacy_metric/common/expression_functions/metric_vis_function.test.ts diff --git a/src/plugins/chart_expressions/expression_legacy_metric/common/expression_functions/metric_vis_function.ts b/src/plugins/chart_expressions/expression_legacy_metric/common/expression_functions/metric_vis_function.ts new file mode 100644 index 00000000000000..8ec638d139bff9 --- /dev/null +++ b/src/plugins/chart_expressions/expression_legacy_metric/common/expression_functions/metric_vis_function.ts @@ -0,0 +1,206 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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'; + +import { + prepareLogTable, + Dimension, + validateAccessor, +} from '@kbn/visualizations-plugin/common/utils'; +import { ColorMode } from '@kbn/charts-plugin/common'; +import { visType } from '../types'; +import { MetricVisExpressionFunctionDefinition } from '../types'; +import { EXPRESSION_METRIC_NAME, LabelPosition } from '../constants'; + +const errors = { + severalMetricsAndColorFullBackgroundSpecifiedError: () => + i18n.translate( + 'expressionLegacyMetricVis.function.errors.severalMetricsAndColorFullBackgroundSpecified', + { + defaultMessage: + 'Full background coloring cannot be applied to a visualization with multiple metrics.', + } + ), + splitByBucketAndColorFullBackgroundSpecifiedError: () => + i18n.translate( + 'expressionLegacyMetricVis.function.errors.splitByBucketAndColorFullBackgroundSpecified', + { + defaultMessage: + 'Full background coloring cannot be applied to visualizations that have a bucket specified.', + } + ), +}; + +export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ + name: EXPRESSION_METRIC_NAME, + type: 'render', + inputTypes: ['datatable'], + help: i18n.translate('expressionLegacyMetricVis.function.help', { + defaultMessage: 'Metric visualization', + }), + args: { + percentageMode: { + types: ['boolean'], + default: false, + help: i18n.translate('expressionLegacyMetricVis.function.percentageMode.help', { + defaultMessage: 'Shows metric in percentage mode. Requires colorRange to be set.', + }), + }, + colorMode: { + types: ['string'], + default: `"${ColorMode.None}"`, + options: [ColorMode.None, ColorMode.Labels, ColorMode.Background], + help: i18n.translate('expressionLegacyMetricVis.function.colorMode.help', { + defaultMessage: 'Which part of metric to color', + }), + strict: true, + }, + colorFullBackground: { + types: ['boolean'], + default: false, + help: i18n.translate('expressionLegacyMetricVis.function.colorFullBackground.help', { + defaultMessage: 'Applies the selected background color to the full visualization container', + }), + }, + palette: { + types: ['palette'], + help: i18n.translate('expressionLegacyMetricVis.function.palette.help', { + defaultMessage: 'Provides colors for the values, based on the bounds.', + }), + }, + showLabels: { + types: ['boolean'], + default: true, + help: i18n.translate('expressionLegacyMetricVis.function.showLabels.help', { + defaultMessage: 'Shows labels under the metric values.', + }), + }, + font: { + types: ['style'], + help: i18n.translate('expressionLegacyMetricVis.function.font.help', { + defaultMessage: 'Font settings.', + }), + default: `{font size=60 align="center"}`, + }, + labelFont: { + types: ['style'], + help: i18n.translate('expressionLegacyMetricVis.function.labelFont.help', { + defaultMessage: 'Label font settings.', + }), + default: `{font size=24 align="center"}`, + }, + labelPosition: { + types: ['string'], + options: [LabelPosition.BOTTOM, LabelPosition.TOP], + help: i18n.translate('expressionLegacyMetricVis.function.labelPosition.help', { + defaultMessage: 'Label position', + }), + default: LabelPosition.BOTTOM, + strict: true, + }, + metric: { + types: ['string', 'vis_dimension'], + help: i18n.translate('expressionLegacyMetricVis.function.metric.help', { + defaultMessage: 'metric dimension configuration', + }), + required: true, + multi: true, + }, + bucket: { + types: ['string', 'vis_dimension'], + help: i18n.translate('expressionLegacyMetricVis.function.bucket.help', { + defaultMessage: 'bucket dimension configuration', + }), + }, + autoScale: { + types: ['boolean'], + help: i18n.translate('expressionLegacyMetricVis.function.autoScale.help', { + defaultMessage: 'Enable auto scale', + }), + required: false, + }, + }, + fn(input, args, handlers) { + if (args.percentageMode && !args.palette?.params) { + throw new Error('Palette must be provided when using percentageMode'); + } + + // currently we can allow colorize full container only for one metric + if (args.colorFullBackground) { + if (args.bucket) { + throw new Error(errors.splitByBucketAndColorFullBackgroundSpecifiedError()); + } + + if (args.metric.length > 1 || input.rows.length > 1) { + throw new Error(errors.severalMetricsAndColorFullBackgroundSpecifiedError()); + } + } + + args.metric.forEach((metric) => validateAccessor(metric, input.columns)); + validateAccessor(args.bucket, input.columns); + + if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + + const argsTable: Dimension[] = [ + [ + args.metric, + i18n.translate('expressionLegacyMetricVis.function.dimension.metric', { + defaultMessage: 'Metric', + }), + ], + ]; + if (args.bucket) { + argsTable.push([ + [args.bucket], + i18n.translate('expressionLegacyMetricVis.function.dimension.splitGroup', { + defaultMessage: 'Split group', + }), + ]); + } + const logTable = prepareLogTable(input, argsTable, true); + handlers.inspectorAdapters.tables.logDatatable('default', logTable); + } + + return { + type: 'render', + as: EXPRESSION_METRIC_NAME, + value: { + visData: input, + visType, + visConfig: { + metric: { + palette: args.palette?.params, + percentageMode: args.percentageMode, + metricColorMode: args.colorMode, + labels: { + show: args.showLabels, + position: args.labelPosition, + style: { + ...args.labelFont, + }, + }, + colorFullBackground: args.colorFullBackground, + style: { + bgColor: args.colorMode === ColorMode.Background, + labelColor: args.colorMode === ColorMode.Labels, + ...args.font, + }, + autoScale: args.autoScale, + }, + dimensions: { + metrics: args.metric, + ...(args.bucket ? { bucket: args.bucket } : {}), + }, + }, + }, + }; + }, +}); diff --git a/src/plugins/chart_expressions/expression_legacy_metric/common/index.ts b/src/plugins/chart_expressions/expression_legacy_metric/common/index.ts new file mode 100755 index 00000000000000..34cbdb67453122 --- /dev/null +++ b/src/plugins/chart_expressions/expression_legacy_metric/common/index.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. + */ + +export const PLUGIN_ID = 'expressionLegacyMetricVis'; +export const PLUGIN_NAME = 'expressionLegacyMetricVis'; + +export type { + MetricArguments, + MetricInput, + MetricVisRenderConfig, + MetricVisExpressionFunctionDefinition, + DimensionsVisParam, + MetricVisParam, + VisParams, + MetricOptions, +} from './types'; + +export { metricVisFunction } from './expression_functions'; + +export { EXPRESSION_METRIC_NAME } from './constants'; diff --git a/src/plugins/chart_expressions/expression_legacy_metric/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_legacy_metric/common/types/expression_functions.ts new file mode 100644 index 00000000000000..5ad540fc579f61 --- /dev/null +++ b/src/plugins/chart_expressions/expression_legacy_metric/common/types/expression_functions.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 type { PaletteOutput } from '@kbn/coloring'; +import { + Datatable, + ExpressionFunctionDefinition, + ExpressionValueRender, + Style, +} from '@kbn/expressions-plugin'; +import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; +import { ColorMode, CustomPaletteState } from '@kbn/charts-plugin/common'; +import { VisParams, visType, LabelPositionType } from './expression_renderers'; +import { EXPRESSION_METRIC_NAME } from '../constants'; + +export interface MetricArguments { + percentageMode: boolean; + colorMode: ColorMode; + showLabels: boolean; + palette?: PaletteOutput; + font: Style; + labelFont: Style; + labelPosition: LabelPositionType; + metric: Array; + bucket?: ExpressionValueVisDimension | string; + colorFullBackground: boolean; + autoScale?: boolean; +} + +export type MetricInput = Datatable; + +export interface MetricVisRenderConfig { + visType: typeof visType; + visData: Datatable; + visConfig: Pick; +} + +export type MetricVisExpressionFunctionDefinition = ExpressionFunctionDefinition< + typeof EXPRESSION_METRIC_NAME, + MetricInput, + MetricArguments, + ExpressionValueRender +>; diff --git a/src/plugins/chart_expressions/expression_legacy_metric/common/types/expression_renderers.ts b/src/plugins/chart_expressions/expression_legacy_metric/common/types/expression_renderers.ts new file mode 100644 index 00000000000000..8c370480a7be99 --- /dev/null +++ b/src/plugins/chart_expressions/expression_legacy_metric/common/types/expression_renderers.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 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 { $Values } from '@kbn/utility-types'; +import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; +import { + ColorMode, + Labels, + CustomPaletteState, + Style as ChartStyle, +} from '@kbn/charts-plugin/common'; +import { Style } from '@kbn/expressions-plugin/common'; +import { LabelPosition } from '../constants'; + +export const visType = 'metric'; + +export interface DimensionsVisParam { + metrics: Array; + bucket?: ExpressionValueVisDimension | string; +} + +export type LabelPositionType = $Values; + +export type MetricStyle = Style & Pick; + +export type LabelsConfig = Labels & { style: Style; position: LabelPositionType }; +export interface MetricVisParam { + percentageMode: boolean; + percentageFormatPattern?: string; + metricColorMode: ColorMode; + palette?: CustomPaletteState; + labels: LabelsConfig; + style: MetricStyle; + colorFullBackground: boolean; + autoScale?: boolean; +} + +export interface VisParams { + addTooltip: boolean; + addLegend: boolean; + dimensions: DimensionsVisParam; + metric: MetricVisParam; + type: typeof visType; +} + +export interface MetricOptions { + value: string; + label: string; + color?: string; + bgColor?: string; + lightText: boolean; + colIndex: number; + rowIndex: number; +} diff --git a/src/plugins/chart_expressions/expression_legacy_metric/common/types/index.ts b/src/plugins/chart_expressions/expression_legacy_metric/common/types/index.ts new file mode 100644 index 00000000000000..9c50bfab1305d3 --- /dev/null +++ b/src/plugins/chart_expressions/expression_legacy_metric/common/types/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 * from './expression_functions'; +export * from './expression_renderers'; diff --git a/src/plugins/chart_expressions/expression_legacy_metric/jest.config.js b/src/plugins/chart_expressions/expression_legacy_metric/jest.config.js new file mode 100644 index 00000000000000..6b649ca8abadcd --- /dev/null +++ b/src/plugins/chart_expressions/expression_legacy_metric/jest.config.js @@ -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 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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../', + roots: ['/src/plugins/chart_expressions/expression_legacy_metric'], + coverageDirectory: + '/target/kibana-coverage/jest/src/plugins/chart_expressions/expression_legacy_metric', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/src/plugins/chart_expressions/expression_legacy_metric/{common,public,server}/**/*.{ts,tsx}', + ], +}; diff --git a/src/plugins/chart_expressions/expression_legacy_metric/kibana.json b/src/plugins/chart_expressions/expression_legacy_metric/kibana.json new file mode 100755 index 00000000000000..0c3489ddc55d11 --- /dev/null +++ b/src/plugins/chart_expressions/expression_legacy_metric/kibana.json @@ -0,0 +1,21 @@ +{ + "id": "expressionLegacyMetricVis", + "version": "1.0.0", + "kibanaVersion": "kibana", + "owner": { + "name": "Vis Editors", + "githubTeam": "kibana-vis-editors" + }, + "description": "Adds a `metric` renderer and function to the expression plugin. The renderer will display the `legacy metric` chart.", + "server": true, + "ui": true, + "requiredPlugins": [ + "expressions", + "fieldFormats", + "charts", + "visualizations", + "presentationUtil" + ], + "requiredBundles": ["kibanaUtils", "kibanaReact"], + "optionalPlugins": [] +} diff --git a/src/plugins/chart_expressions/expression_metric/public/__mocks__/format_service.ts b/src/plugins/chart_expressions/expression_legacy_metric/public/__mocks__/format_service.ts similarity index 100% rename from src/plugins/chart_expressions/expression_metric/public/__mocks__/format_service.ts rename to src/plugins/chart_expressions/expression_legacy_metric/public/__mocks__/format_service.ts diff --git a/src/plugins/chart_expressions/expression_metric/public/__mocks__/palette_service.ts b/src/plugins/chart_expressions/expression_legacy_metric/public/__mocks__/palette_service.ts similarity index 100% rename from src/plugins/chart_expressions/expression_metric/public/__mocks__/palette_service.ts rename to src/plugins/chart_expressions/expression_legacy_metric/public/__mocks__/palette_service.ts diff --git a/src/plugins/chart_expressions/expression_metric/public/__mocks__/services.ts b/src/plugins/chart_expressions/expression_legacy_metric/public/__mocks__/services.ts similarity index 100% rename from src/plugins/chart_expressions/expression_metric/public/__mocks__/services.ts rename to src/plugins/chart_expressions/expression_legacy_metric/public/__mocks__/services.ts diff --git a/src/plugins/chart_expressions/expression_metric/public/__stories__/metric_renderer.stories.tsx b/src/plugins/chart_expressions/expression_legacy_metric/public/__stories__/metric_renderer.stories.tsx similarity index 100% rename from src/plugins/chart_expressions/expression_metric/public/__stories__/metric_renderer.stories.tsx rename to src/plugins/chart_expressions/expression_legacy_metric/public/__stories__/metric_renderer.stories.tsx diff --git a/src/plugins/chart_expressions/expression_metric/public/components/__snapshots__/metric_component.test.tsx.snap b/src/plugins/chart_expressions/expression_legacy_metric/public/components/__snapshots__/metric_component.test.tsx.snap similarity index 99% rename from src/plugins/chart_expressions/expression_metric/public/components/__snapshots__/metric_component.test.tsx.snap rename to src/plugins/chart_expressions/expression_legacy_metric/public/components/__snapshots__/metric_component.test.tsx.snap index ac950f3f7f2c46..106d45bc4a87c8 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/__snapshots__/metric_component.test.tsx.snap +++ b/src/plugins/chart_expressions/expression_legacy_metric/public/components/__snapshots__/metric_component.test.tsx.snap @@ -116,4 +116,4 @@ exports[`MetricVisComponent should render correct structure for single metric 1` } } /> -`; +`; \ No newline at end of file diff --git a/src/plugins/chart_expressions/expression_metric/public/components/__snapshots__/with_auto_scale.test.tsx.snap b/src/plugins/chart_expressions/expression_legacy_metric/public/components/__snapshots__/with_auto_scale.test.tsx.snap similarity index 100% rename from src/plugins/chart_expressions/expression_metric/public/components/__snapshots__/with_auto_scale.test.tsx.snap rename to src/plugins/chart_expressions/expression_legacy_metric/public/components/__snapshots__/with_auto_scale.test.tsx.snap diff --git a/src/plugins/chart_expressions/expression_metric/public/components/metric.scss b/src/plugins/chart_expressions/expression_legacy_metric/public/components/metric.scss similarity index 81% rename from src/plugins/chart_expressions/expression_metric/public/components/metric.scss rename to src/plugins/chart_expressions/expression_legacy_metric/public/components/metric.scss index c99c191c577553..7adcb109bc931c 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/metric.scss +++ b/src/plugins/chart_expressions/expression_legacy_metric/public/components/metric.scss @@ -5,7 +5,7 @@ // mtrChart__legend--small // mtrChart__legend-isLoading -.mtrVis { +.legacyMtrVis { @include euiScrollBar; height: 100%; width: 100%; @@ -17,23 +17,23 @@ overflow: auto; } -.mtrVis__value { +.legacyMtrVis__value { @include euiTextTruncate; font-weight: $euiFontWeightBold; } -.mtrVis__container { +.legacyMtrVis__container { text-align: center; padding: $euiSize; display: flex; flex-direction: column; } -.mtrVis__container--light { +.legacyMtrVis__container--light { color: $euiColorEmptyShade; } -.mtrVis__container-isfull { +.legacyMtrVis__container-isfull { min-height: 100%; min-width: max-content; display: flex; @@ -43,13 +43,14 @@ flex: 1 0 100%; } -.mtrVis__container-isFilterable { +.legacyMtrVis__container-isFilterable { cursor: pointer; transition: transform $euiAnimSpeedNormal $euiAnimSlightResistance; transform: translate(0, 0); - &:hover, &:focus { + &:hover, + &:focus { box-shadow: none; transform: translate(0, -2px); } -} +} \ No newline at end of file diff --git a/src/plugins/chart_expressions/expression_metric/public/components/metric_component.test.tsx b/src/plugins/chart_expressions/expression_legacy_metric/public/components/metric_component.test.tsx similarity index 100% rename from src/plugins/chart_expressions/expression_metric/public/components/metric_component.test.tsx rename to src/plugins/chart_expressions/expression_legacy_metric/public/components/metric_component.test.tsx diff --git a/src/plugins/chart_expressions/expression_metric/public/components/metric_component.tsx b/src/plugins/chart_expressions/expression_legacy_metric/public/components/metric_component.tsx similarity index 100% rename from src/plugins/chart_expressions/expression_metric/public/components/metric_component.tsx rename to src/plugins/chart_expressions/expression_legacy_metric/public/components/metric_component.tsx diff --git a/src/plugins/chart_expressions/expression_metric/public/components/metric_value.test.tsx b/src/plugins/chart_expressions/expression_legacy_metric/public/components/metric_value.test.tsx similarity index 92% rename from src/plugins/chart_expressions/expression_metric/public/components/metric_value.test.tsx rename to src/plugins/chart_expressions/expression_legacy_metric/public/components/metric_value.test.tsx index fee24d8aa5e7f4..0590faebe5f7df 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/metric_value.test.tsx +++ b/src/plugins/chart_expressions/expression_legacy_metric/public/components/metric_value.test.tsx @@ -75,7 +75,7 @@ describe('MetricVisValue', () => { /> ); component.simulate('click'); - expect(component.find('.mtrVis__container-isfilterable')).toHaveLength(1); + expect(component.find('.legacyMtrVis__container-isfilterable')).toHaveLength(1); }); it('should not add -isfilterable class if onFilter is not provided', () => { @@ -88,7 +88,7 @@ describe('MetricVisValue', () => { /> ); component.simulate('click'); - expect(component.find('.mtrVis__container-isfilterable')).toHaveLength(0); + expect(component.find('.legacyMtrVis__container-isfilterable')).toHaveLength(0); }); it('should call onFilter callback if provided', () => { @@ -116,6 +116,6 @@ describe('MetricVisValue', () => { labelConfig={labelConfig} /> ); - expect(component.find('.mtrVis__container-isfull').exists()).toBe(true); + expect(component.find('.legacyMtrVis__container-isfull').exists()).toBe(true); }); }); diff --git a/src/plugins/chart_expressions/expression_metric/public/components/metric_value.tsx b/src/plugins/chart_expressions/expression_legacy_metric/public/components/metric_value.tsx similarity index 88% rename from src/plugins/chart_expressions/expression_metric/public/components/metric_value.tsx rename to src/plugins/chart_expressions/expression_legacy_metric/public/components/metric_value.tsx index 40ba0eb0815648..1f9192aedc8726 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/metric_value.tsx +++ b/src/plugins/chart_expressions/expression_legacy_metric/public/components/metric_value.tsx @@ -23,10 +23,10 @@ interface MetricVisValueProps { export const MetricVisValue = (props: MetricVisValueProps) => { const { style, metric, onFilter, labelConfig, colorFullBackground, autoScale } = props; - const containerClassName = classNames('mtrVis__container', { - 'mtrVis__container--light': metric.lightText, - 'mtrVis__container-isfilterable': onFilter, - 'mtrVis__container-isfull': !autoScale && colorFullBackground, + const containerClassName = classNames('legacyMtrVis__container', { + 'legacyMtrVis__container--light': metric.lightText, + 'legacyMtrVis__container-isfilterable': onFilter, + 'legacyMtrVis__container-isfull': !autoScale && colorFullBackground, }); useLayoutEffect(() => { @@ -41,7 +41,7 @@ export const MetricVisValue = (props: MetricVisValueProps) => { >
{